Skip to content

v2.9.0

Choose a tag to compare

@heskew heskew released this 02 Oct 23:08
6ba1ff4

Added

  • The ID-token issuer is now derived automatically for almost every configuration, closing the gap #231 §4/#263 left open (#264). Three mechanisms, all operator-invisible unless something goes wrong:
    • Okta custom authorization servers: configure() (and TenantManager.registerTenant) now accept an optional authServer alongside domain (e.g. authServer: 'default'), deriving both the path-inclusive endpoints and the issuer (https://{domain}/oauth2/{authServer}) for a named custom authorization server — previously only the org authorization server's shortcut existed, and a custom AS required explicit endpoints with no derived issuer at all.
    • OIDC discovery for any other statically configured, explicit-endpoint JWKS provider with no usable issuer: a bounded background attempt probes .well-known/openid-configuration at ancestor path-prefixes of authorizationUrl's own origin (never cross-origin), and accepts a result only when its issuer, authorization_endpoint, jwks_uri, and token_endpoint all exactly match the configured values. Fire-and-forget — never blocks startup, and is scheduled only once a reload's provider registry is actually published, never for a build later discarded. The first login that needs it awaits the in-flight attempt with a 3-second bound; every other login during that window doesn't wait, per the issue's own design. A successful result is cached and logged (issuer derived via discovery: X — add issuer: X to pin it) for the process lifetime; a failure is retried at most once every 5 minutes, and only via a fresh reload — never from a login. A dynamically resolved (onResolveProvider) provider never fetches on its own, but can read an already-cached result from a static provider sharing the same endpoints.
    • Azure multi-tenant adoption: pinning issuer to a single tenant's issuer URI (either form — the v2 https://login.microsoftonline.com/<tenant-guid>/v2.0, or the older v1 https://sts.windows.net/<tenant-guid>/, matching whichever form authorizationUrl actually issues) alongside a multi-tenant tenantId/authorizationUrl (common/organizations/consumers) turns the provider single-tenant for that one tenant — verification is redirected to that tenant's own, non-shared JWKS endpoint (never Microsoft's shared, tenant-independent key set) and that tenant's issuer is enforced exactly like an ordinary single-tenant Azure config, so a login from any other tenant now fails ID-token verification outright, not just adoption. Unpinned, a multi-tenant provider (authorizationUrl and jwksUri both naming the shared alias — the normal shape, including the Azure preset's default) is completely unaffected (byte-identical to today: issuerValidated always false). One exotic explicit-endpoint shape is the exception: an authorizationUrl naming a shared alias (or a different tenant) paired with a jwksUri naming one specific real tenant GUID directly — previously had no issuer check at all (any tenant whose token verified against those keys could sign in); it now derives and enforces that one tenant's issuer the same way a pin would, so every other tenant's login now fails ID-token verification outright. A startup warning names the provider and both tenants when this happens (for a dynamically resolved provider, only when its hook returns a raw, unbuilt config for this plugin to build — a hook that calls buildProviderConfig itself derives the issuer, and would need to pass its own logger, before this plugin ever sees the result), with the fix (point authorizationUrl/jwksUri at the same tenant, or pin issuer explicitly to make the restriction obvious in your own config). Pinning an array of issuers on a multi-tenant provider is rejected at startup — it would make every listed tenant adoption-eligible through one provider, reopening a cross-tenant identity question that belongs to account-level (iss, sub) binding (#231 §3), not issuer validation; configure a separate provider entry per tenant instead.
    • A new startup warning (not an error) for a statically configured provider with fetchEmail: true against a Microsoft Graph UserInfo endpoint whose scope omits profile — closing the silent-oid-loss gap #263 documented but didn't warn about.
  • onResolveEmail hook: choose which of GitHub's several verified emails becomes the login identity (#228). GitHub's /user/emails can return more than one address with independent verified/primary flags; the plugin previously picked one deterministically (public profile email, else primary) and discarded the rest with no way for an application to choose differently. Registering onResolveEmail(candidates, provider) now lets an application pick among them — e.g. "prefer the address on my company domain" — while the plugin independently validates the result against the exact set GitHub's authenticated fetch returned: the hook may only ever resolve to an address with verified: true, never an arbitrary or unverified one, so this cannot become a way to adopt a different account than the one the authenticated fetch actually confirmed. An invalid pick, a thrown error, or a hook that doesn't settle within 5 seconds fails the login rather than silently falling back to the default — a silent fallback could otherwise establish a session for a different account than the one the hook was trying to reach. Registering the hook is opt-in; the built-in default it sits above is not — see the matching entry below for the behavior change that applies regardless of whether this hook is used. See Lifecycle Hooks: onResolveEmail. Note GitHub's default usernameClaim is login (the handle), not email — set usernameClaim: 'email' for the resolved address to also become session.user.

Changed

  • The #231 §4 startup hard-fail is relaxed: an https explicit-endpoint JWKS provider with no usable issuer now boots and resolves it via OIDC discovery in the background, instead of failing to start (#264). Only a config that's wrong independent of the network — no authorizationUrl, or a non-https one — still fails fast at startup, naming the provider and the issuer key. Upgrade note: a provider that previously failed to start for this reason now boots; while discovery is pending, or if it never resolves, logins through it behave exactly as any other "signed but not issuer-validated" login already does (see the Account-Adoption Gate docs) — an existing-account login is denied, a new one is quarantined. This is not a trust downgrade: issuerValidated simply becomes true more often, once discovery (or an operator's own issuer pin) resolves it.

  • Azure's configure() no longer sets a literal, incorrect issuer for the common/organizations/consumers tenant aliases (#264). Previously, explicitly setting tenantId: 'common' (redundant with the default, but sometimes written for clarity) produced a literal issuer: 'https://login.microsoftonline.com/common/v2.0' — a value no real multi-tenant token's iss could ever equal, since a real token's iss always names its actual tenant GUID. This was latent (Azure was previously excluded from the #231 §4 check entirely) but is now fixed outright: these aliases leave issuer unset unless the operator also pins one (see Added, above).

  • buildProviderConfig's configure() dispatch now switches on the resolved preset's own provider field, not the final config.provider string (#264). An explicit provider: 'microsoft' option previously bypassed configure() entirely — getProvider('microsoft') correctly resolved the Azure preset, but the dispatch switch compared against the literal string 'microsoft', which never matched 'azure' — so a provider: 'microsoft' + tenantId: <guid> config silently kept the default /common endpoints with no derived issuer. provider: 'microsoft' now derives endpoints and issuer exactly like provider: 'azure'.

  • An explicit issuer is no longer silently overwritten by a domain/tenantId shortcut (#264). Previously, configure()'s returned issuer always overwrote an operator-pinned one via an unconditional Object.assign; an operator who paired domain/tenantId with an explicit issuer (e.g. to override a quirky custom authorization server, or to pin a multi-tenant Azure provider to one tenant) had that pin silently discarded — this affected Azure too: providers/azure.ts returned an issuer for every tenantId, including the multi-tenant aliases, and that return value was applied the same unconditional way. Only issuer is protected this way — every other configure()-returned field (the endpoint URLs) keeps today's unconditional-overwrite behavior, so a shortcut's endpoints stay mutually coherent rather than mixing a stale explicit endpoint with freshly-derived others. Azure upgrade note: a pin now fails in two independent ways — either is enough to take the provider down the same paths described below:

    • Naming a different tenant. A tenantId paired with an explicit issuer that doesn't name that same tenant now fails instead of booting with the (previously silently discarded) stale pin — for example a pin left over after switching tenantId through an env var, the .../common/v2.0 or {tenantid} placeholder value copied from the docs, or a pin missing its /v2.0 suffix. Message: OAuth provider '<name>' (azure) has a jwksUri for tenant '<tenantId>' but an explicit 'issuer' naming a different tenant ... (<pin>).
    • Naming the right tenant, but the wrong issuer FORM. Azure has two issuer forms — v2 (https://login.microsoftonline.com/<tenant-guid>/v2.0) and the older v1 (https://sts.windows.net/<tenant-guid>/) — and which one a real token carries depends on authorizationUrl (the tenantId preset shortcut always generates a v2 authorizationUrl, so a v1 pin — or a same-tenant [v2, v1] array — on a preset-derived config now fails even though it names the correct tenant). This is new: previously the preset's configure() silently overwrote any pin, so a v1 pin on a v2-authorize config (e.g. one copied from a Graph access token's iss, which is v1) booted and worked, just without adoption. Message: OAuth provider '<name>' (azure) pins 'issuer' to '<pin>', which names the right tenant but the wrong Azure issuer form for its 'authorizationUrl' ('<authorizationUrl>') ....

    For a statically declared provider (providers: in config), that provider is skipped at startup (logged as an error) rather than aborting every other declared provider's startup — see the Fixed entry below. A dynamically resolved provider (onResolveProvider returning a raw Azure config with either kind of mismatch) fails differently, since there's no startup to abort: the resolving request gets a 500 (Failed to resolve OAuth provider), and — for a login already in session — the automatic session-validation middleware treats the unresolvable provider the same as a deleted one and clears that session on its next request. Nothing shows at startup for this case; look for OAuth provider '<name>' (dynamic resolution in the log (, session validation is appended for the middleware's own copy of this check) — the rest of that line is one of the two messages above. A failed dynamic resolution of this kind is cached for 30 seconds so a bad config doesn't re-run the resolve hook on every single request; a corrected config takes effect on the next request after that window, no restart needed.

  • GitHub logins with several verified emails now match an existing Harper account before falling back to the profile-or-primary default (#228). Previously, a user whose GitHub profile/primary address differed from the address their existing Harper account was created under could never reach that account through this plugin — only the exact profile-or-primary pick. Only applies when usernameClaim: 'email' is configured — hdb_user isn't keyed by email under any other username claim (including GitHub's default, login), so this step is skipped entirely with no reads and no behavior change for those deployments. When it applies: the plugin checks each VERIFIED address from GitHub's authenticated /user/emails fetch against hdb_user, using the exact same exact-match lookup the account-adoption gate itself already uses (no normalization): if exactly one matches an existing account, that address is used; if none match, today's original default (public profile email, else primary) applies unchanged. A registered onResolveEmail hook always overrides this default when it expresses a preference, and its candidates now also carry a profile: boolean field (which of them is the public profile address) so a hook can reconstruct the exact original default if needed. New failure modes: two or more verified addresses matching DIFFERENT existing accounts refuses the login (reason=email_ambiguous) rather than guessing — UNLESS the current profile-or-primary address is one of the matches, in which case that one wins (it reproduces exactly what every login already resolves to today, not a guess between strangers — this covers a user who changed their GitHub primary address, where both the old and new address may have existing accounts from when each was primary); the log names the match count, never the addresses. An hdb_user read failure during this check refuses with a retryable reason=email_lookup_failed rather than treating the failure as "no match". Reads hdb_user once per verified address, skipping entirely only when usernameClaim: 'email' isn't set, there are no verified addresses, or the lone verified address already IS the verified profile-or-primary default pick (nothing to disambiguate) — an unverified profile/primary address with exactly one (different) verified address still costs one read, since that's the #228 case itself. See Account-Adoption Gate: GitHub default email. Upgrade note: with usernameClaim: 'email', a GitHub user whose profile/primary address has no Harper account of its own, but who has another verified address matching exactly one existing Harper account B, will now log into B the first time they log in after upgrading. A user whose profile/primary address is itself verified and already has an account stays on it, even when another verified address also matches an account — the default-pick exception above keeps that login unchanged. An unverified profile/primary address isn't part of this matching at all: regardless of whether it happens to have an hdb_user row of its own, the login still resolves through whichever other verified address matches, same as if that account didn't exist. This is the intended fix for #228. To opt back out, register onResolveEmail returning const pick = candidates.find((c) => c.profile) ?? candidates.find((c) => c.primary); return pick?.verified ? pick.email : undefined; — this reproduces the previous pick whenever that address was verified; the pick?.verified guard matters because onResolveEmail can only ever return a verified address, so returning an unverified pick would fail the login outright (reason=email_selection) instead of reproducing it. A GitHub account whose profile/primary address is itself unverified (login still succeeds today, just without account adoption) has no hook-based way back to that exact unverified pick — only this behavior change itself; the snippet above returns undefined for that case, which defers to the built-in default rather than failing the login. Rolling upgrades and multi-node clusters: see the docs link above for replication-lag and per-node skew during the upgrade window.

  • MCP access tokens now carry typ: at+jwt in their JWT header (RFC 9068 §2.1), closing a gap docs/mcp-oauth-conformance.md tracked from the 2026-07-28 conformance pass (#202). withMCPAuth's production verifier (verifyAccessTokenWithKeySet) now enforces the header (RFC 9068 §4: the resource server MUST verify typ), accepting at+jwt and the prior default JWT — a token already minted with typ: JWT keeps verifying rather than being invalidated outright; since every token minted from here on carries typ: at+jwt, the JWT allowance is self-limiting (it stops mattering once any pre-upgrade token could have expired) but isn't itself time-boxed in code — tracked for removal in #258. Compatibility: a consumer that pinned typ: JWT on these tokens must accept at+jwt going forward.

Fixed

  • An unpinned Azure shared-alias provider on the older v1 keys shape (.../common/discovery/keys, no v2.0 — Azure's actual v1 metadata jwks_uri) is recognized and excluded the same way the v2 alias shape already is, instead of falling through unrecognized (#264/#271). Before this fix, this exact shape had no special handling at all: it fell into background OIDC discovery (which fails for it — Azure's v1 discovery document's sts.windows.net issuer can never equal a probed login.microsoftonline.com ancestor), and the resulting log told the operator to pin issuer explicitly — landing them on a config that binds a pin to Azure's shared, tenant-independent v1 key pool, which this plugin's whole Azure design (see src/lib/DESIGN.md) exists to prevent. Unpinned, this shape is now byte-identical to main, exactly like the v2 alias. Pinning issuer on this shape now fails at startup instead (for a statically declared provider, skipped with a logged error, same as any other invalid Azure pin — see the entries below; for a dynamically resolved one, the usual 500 + cooldown), naming the fix: point jwksUri at the tenant's own keys endpoint (v1 or v2) instead of the shared one.
  • The authorizationUrl/jwksUri Azure tenant-mismatch startup warning (#264/#271, see Added above) no longer false-positives on a verified tenant DOMAIN name. authorizationUrl's tenant segment can legitimately be a domain Azure accepts in place of a GUID (e.g. contoso.onmicrosoft.com), which is never equal to jwksUri's GUID segment as a plain string — the warning previously fired for every such config, even though nothing is actually misconfigured and there's no offline way to confirm the domain and GUID name the same tenant. The warning now only compares authorizationUrl's segment against jwksUri's tenant when it's itself an alias (common/organizations/consumers) or another real tenant GUID; a domain segment is silently left alone, same as before #264. The same fix also stops a false positive on the one alias/GUID pair that does name the same tenant: authorizationUrl's consumers alias and jwksUri's fixed AZURE_CONSUMERS_TENANT_ID GUID.
  • The authorizationUrl/jwksUri Azure tenant-mismatch warning now actually reaches the log for a dynamically resolved (onResolveProvider) provider whose hook returns a raw, unbuilt config. Both dynamic-resolution call sites (resource.ts's REST GET handler and index.ts's session-validation middleware) call buildProviderConfig without a logger — this warning never fired for a dynamic provider at all, regardless of how the config was built. They're passed a logger now, deduped per provider (DynamicProviderCache.wrapLoggerForDynamicResolution): the first distinct warning message for a given provider name logs; a repeat of the exact same message for that provider is dropped, so a misconfigured dynamic provider warns once rather than on every request (or every TTL-refresh with cacheDynamicProviders set to a short window or false). clear() resets this dedup state along with the rest of the cache's per-provider tracking.
  • /login now redirects with a reason code when CSRF state storage fails, instead of letting the raw error escape (#227): handleLogin awaited generateCSRFToken → csrfTokenManager.set() with no error handling, so a table-write failure (e.g. a wedged write queue, HarperFast/harper#2450) surfaced to the browser as core's raw JSON error response instead of the app's sign-in page. handleCallback already follows this pattern for its own failures (e.g. the account-adoption gate's account_lookup_failed) rather than letting a raw error escape; /login now matches it: a generateCSRFToken rejection is logged server-side and redirects to originalUrl (sanitized by the same buildErrorRedirect the other reason codes use) with error=server_error&reason=state_storage, with no Set-Cookie (the browser secret is only useful once a state token exists). Sign-in still can't proceed while storage is down — this only changes where the failure lands.
  • A provider with an invalid Azure issuer pin (see the Changed entry above) is now skipped with a logged error at startup, instead of aborting every other declared provider (#264). initializeProviders calls buildProviderConfig for each declared provider in one loop with no per-provider try/catch; a throw from one provider's config (previously only the pre-existing #231 §4 "issuer required" and redirectUri checks, now also this new Azure-pin validation) propagated out of the loop entirely, so a reload or boot carrying one misconfigured Azure provider failed to start every other provider too — inconsistent with #259/#260's own "skip the bad one, keep the others" precedent for every other kind of per-provider misconfiguration. Only this new failure mode is downgraded this way; #231 §4's issuer-required check and other buildProviderConfig failures are unrelated to one provider's own pin and keep today's fail-the-whole-reload behavior.
  • A dynamically resolved (onResolveProvider) provider with the same invalid Azure issuer pin no longer re-runs the resolve hook (and rebuilds, and re-throws) on every single request for that provider (#264). Both of the dynamic-resolution call sites — the REST GET handler (resource.ts) and the automatic session-validation middleware (index.ts) — now cache the failure for a fixed 30 seconds (DynamicProviderCache.recordAzurePinFailure/getAzurePinFailure, 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) and log the same specific tenant-mismatch message instead of a generic "Error resolving provider". A corrected config takes effect on the next request after that window elapses, with no restart or manual cache-clear needed.
  • A failed OAuth session clear is no longer reported as a completed logout or invalidation (#265, #266). clearOAuthSession previously returned as if it had succeeded whenever an existing session's .update wasn't callable — silently leaving the stored session's oauth/user in place while the caller believed it was cleared. It now returns false in that case, when .update itself rejects, or when the store write succeeds but the in-memory field clear that follows throws (e.g. a non-configurable session object) — any of those, or a logger throwing while reporting one, is reported as false rather than escaping as a rejection — while still keeping its intentional quiet success for the no-session and anonymous-logout (no id) cases. handleLogout now returns 503 with Cache-Control: no-store instead of a 200 success body on a failed clear, so a client never treats a failed logout as done and can safely retry. The automatic session-validation HTTP middleware reacts the same way at every point it triggers a clear itself (an unrecognized provider config, or a validateAndRefreshSession result reporting the same failure): a request whose invalidation couldn't persist now gets the same 503/no-store instead of continuing to serve it as the stale, supposedly-revoked identity. withOAuthValidation's requireAuth: false pass-through is unaffected by design: it already proceeds on any invalidation, persisted or not. Separately, the token-refresh-failure path's own second clearOAuthSession call (used to log out an expired session whose refresh just failed) could itself reject — e.g. if the session store write failed — escaping as an unhandled rejection instead of resolving validateAndRefreshSession's promise; that call is now wrapped so any failure is logged (without token values) and the function still resolves to a controlled { valid: false }. Both bugs were surfaced while tracing #213's design; neither needed #213's core change.
  • A statically configured JWKS-enabled provider now requires a usable issuer, checked at startup (#231 §4): a custom OIDC/JWKS provider configured with explicit endpoints — e.g. an Okta custom authorization server, or a generic provider with jwksUri set — but no issuer left issuerValidated always false (see 2.6.0's account-adoption gate). Its ID token signature still verified, but a hookless login that should have adopted an existing Harper account was silently denied, with nothing in the logs pointing at issuer. Config building for a provider declared in providers: now throws at startup, naming the provider and the issuer key, whenever it has jwksUri set but no usable issuer (empty string, null, or an array with no non-empty entry) — but only once that provider has also passed #259/#260's configured-ness check, so an unconfigured or half-configured provider is still just skipped, never evaluated for issuer. Okta/Auth0's domain shortcut (and Azure's tenantId shortcut) already derive issuer for you — this only affects a provider bypassing that shortcut with explicit endpoints. Azure's multi-tenant /common default is unaffected: it ships issuer: null deliberately (no single issuer exists for /common) and remains not-adoption-eligible exactly as before, not a startup error. A dynamically resolved provider (onResolveProvider) is also unaffected — enforcing this on the request path would repeatedly fail every request for a misconfigured tenant instead of just leaving it non-adoption-eligible, so the check runs only for statically declared providers. Upgrade note: an instance with a statically declared, fully configured provider that sets jwksUri and no issuer — Okta/Auth0/generic only, not Azure — now fails to start; the same config applied via a live reload is rejected and the previous configuration keeps serving. Set issuer explicitly, or use the domain/tenantId shortcut. Superseded by #264, below (same release): an https authorizationUrl no longer hard-fails here at all — it derives issuer via background OIDC discovery instead; only a non-https authorizationUrl still fails fast as described above.
  • UserInfo sub must match the ID token's sub on the fetchEmail fallback path (#231 §5a, OIDC Core 5.3.2/5.3.4): when an ID token lacks an email claim and fetchEmail is configured, the plugin fetches the UserInfo endpoint to resolve it. A UserInfo response whose sub differs from (or omits, which OIDC Core 5.3.2 also disallows) the verified ID token's sub describes an uncorrelated subject — previously its fields (email, name, and any other claim not present on the ID token) were still merged into the login. The fetched UserInfo is now discarded entirely on a sub mismatch or absence, falling back to the ID token's own claims only, exactly as on a UserInfo fetch failure. This was not an adoption-gate bypass (that merge was always tagged unauthenticated, which the gate already denies for adoption), but it could still attach a different subject's profile fields to the session. Microsoft Graph's non-OIDC /v1.0/me UserInfo shape (the Azure preset's default) is correlated by id/oid instead of sub, so the Azure preset's fetchEmail: true behavior is unaffected — provided the id token carries oid, which requires the profile scope (the preset's default scope includes it; a config that overrides scope to drop profile loses oid and falls back to the discard above — #264, below, adds a startup warning for exactly this combination).
  • A transient hdb_user read error during account adoption now quarantines the login instead of denying it, unless allowUnverifiedClaimInheritance is enabled (#231 §5b): the adoption gate's existence check previously treated a lookup error the same as a confirmed existing account — denying a login with an unverified claim, or, with the escape hatch enabled, adopting it via the hatch. With the escape hatch disabled (the default), the quarantine principal it would otherwise use is unpredictable and can never match any hdb_user name, confirmed-existing or not, so quarantining on an unknown lookup result is exactly as secure as denying — and more available, since a transient error on a login that was never going to adopt anything no longer fails it outright. With the escape hatch enabled, quarantining would be the wrong outcome — if the account does exist, the hatch should adopt it and inherit its role, and a quarantined session would stick roleless until the next login — so a lookup error instead fails the login with a retryable error (error=server_error&reason=account_lookup_failed, logged server-side), leaving no session established, so a retry once the storage fault clears gets the real adopt-vs-quarantine decision. A trusted claim (signed-oidc / github-authenticated) is unaffected either way. Upgrade note: with the escape hatch enabled, a login that hit a transient hdb_user read error previously still succeeded (adopted via the hatch); it now fails with the retryable error above instead and must be retried once the storage fault clears.
  • Disabling allowUnverifiedClaimInheritance is applied independently of provider (re)initialization on a live config reload (#231 §5c): a reload snapshot that both disabled the escape hatch and had an unrelated provider config error (e.g. the new issuer check above) previously aborted before the escape-hatch flag was ever re-applied, leaving the previous snapshot's value — possibly still enabled — in effect, contrary to the operator's intent in the snapshot that failed. A disabling value is now applied to the running configuration immediately, before any other reload validation (including MCP config checks) can throw. An enabling value still waits for the reload to fully succeed, so a rejected reload can never turn the hatch on for the still-serving (old) providers.
  • _emailProvenance is no longer stamped signed-oidc on the no-JWKS fallback (#231 §5d): OAuthProvider.getUserInfo labeled any present idTokenClaims signed-oidc regardless of whether the ID token's signature was actually verified via JWKS — including the no-JWKS fallback, which only decodes claims. The account-adoption gate was not exploitable by this (it independently re-checks signatureVerified/issuerValidated before trusting signed-oidc), but the label overstated its own contract for any other consumer. getUserInfo now takes the verified-signature flag and only stamps signed-oidc when it is true.
  • Token error responses are now tested for Cache-Control: no-store / Pragma: no-cache. The error path (token.ts:errorResponse) already applied the same NO_STORE_HEADERS as the success path; only the success-path assertion existed. No behavior change — closes the other half of the no-store conformance gap (#202).

Known issue

  • Azure configs whose jwksUri uses the ?appid=<client-id> form of the shared-alias keys URL (custom signing keys) with explicit endpoints and no tenantId are not recognized as Azure aliases, and fail at startup with an issuer-discovery error when unpinned. Workaround: set tenantId, or use the tenant-specific keys URL. Fix tracked in #275.