Repository navigation
v2.9.0
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()(andTenantManager.registerTenant) now accept an optionalauthServeralongsidedomain(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-configurationat ancestor path-prefixes ofauthorizationUrl's own origin (never cross-origin), and accepts a result only when itsissuer,authorization_endpoint,jwks_uri, andtoken_endpointall 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
issuerto a single tenant's issuer URI (either form — the v2https://login.microsoftonline.com/<tenant-guid>/v2.0, or the older v1https://sts.windows.net/<tenant-guid>/, matching whichever formauthorizationUrlactually issues) alongside a multi-tenanttenantId/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 (authorizationUrlandjwksUriboth naming the shared alias — the normal shape, including the Azure preset's default) is completely unaffected (byte-identical to today:issuerValidatedalwaysfalse). One exotic explicit-endpoint shape is the exception: anauthorizationUrlnaming a shared alias (or a different tenant) paired with ajwksUrinaming 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 callsbuildProviderConfigitself derives the issuer, and would need to pass its own logger, before this plugin ever sees the result), with the fix (pointauthorizationUrl/jwksUriat the same tenant, or pinissuerexplicitly 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: trueagainst a Microsoft Graph UserInfo endpoint whosescopeomitsprofile— closing the silent-oid-loss gap #263 documented but didn't warn about.
- Okta custom authorization servers:
onResolveEmailhook: choose which of GitHub's several verified emails becomes the login identity (#228). GitHub's/user/emailscan return more than one address with independentverified/primaryflags; the plugin previously picked one deterministically (public profile email, elseprimary) and discarded the rest with no way for an application to choose differently. RegisteringonResolveEmail(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 withverified: 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 defaultusernameClaimislogin(the handle), notemail— setusernameClaim: 'email'for the resolved address to also becomesession.user.
Changed
-
The #231 §4 startup hard-fail is relaxed: an
httpsexplicit-endpoint JWKS provider with no usableissuernow 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 — noauthorizationUrl, or a non-httpsone — still fails fast at startup, naming the provider and theissuerkey. 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:issuerValidatedsimply becomestruemore often, once discovery (or an operator's ownissuerpin) resolves it. -
Azure's
configure()no longer sets a literal, incorrectissuerfor thecommon/organizations/consumerstenant aliases (#264). Previously, explicitly settingtenantId: 'common'(redundant with the default, but sometimes written for clarity) produced a literalissuer: 'https://login.microsoftonline.com/common/v2.0'— a value no real multi-tenant token'sisscould ever equal, since a real token'sissalways 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 leaveissuerunset unless the operator also pins one (see Added, above). -
buildProviderConfig'sconfigure()dispatch now switches on the resolved preset's ownproviderfield, not the finalconfig.providerstring (#264). An explicitprovider: 'microsoft'option previously bypassedconfigure()entirely —getProvider('microsoft')correctly resolved the Azure preset, but the dispatch switch compared against the literal string'microsoft', which never matched'azure'— so aprovider: 'microsoft'+tenantId: <guid>config silently kept the default/commonendpoints with no derived issuer.provider: 'microsoft'now derives endpoints and issuer exactly likeprovider: 'azure'. -
An explicit
issueris no longer silently overwritten by adomain/tenantIdshortcut (#264). Previously,configure()'s returnedissueralways overwrote an operator-pinned one via an unconditionalObject.assign; an operator who paireddomain/tenantIdwith an explicitissuer(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.tsreturned anissuerfor everytenantId, including the multi-tenant aliases, and that return value was applied the same unconditional way. Onlyissueris protected this way — every otherconfigure()-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
tenantIdpaired with an explicitissuerthat 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 switchingtenantIdthrough an env var, the.../common/v2.0or{tenantid}placeholder value copied from the docs, or a pin missing its/v2.0suffix. 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 onauthorizationUrl(thetenantIdpreset shortcut always generates a v2authorizationUrl, 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'sconfigure()silently overwrote any pin, so a v1 pin on a v2-authorize config (e.g. one copied from a Graph access token'siss, 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 (onResolveProviderreturning a raw Azure config with either kind of mismatch) fails differently, since there's no startup to abort: the resolving request gets a500(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 forOAuth provider '<name>' (dynamic resolutionin the log (, session validationis 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. - Naming a different tenant. A
-
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_userisn'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/emailsfetch againsthdb_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, elseprimary) applies unchanged. A registeredonResolveEmailhook always overrides this default when it expresses a preference, and itscandidatesnow also carry aprofile: booleanfield (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. Anhdb_userread failure during this check refuses with a retryablereason=email_lookup_failedrather than treating the failure as "no match". Readshdb_useronce per verified address, skipping entirely only whenusernameClaim: '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: withusernameClaim: '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 anhdb_userrow 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, registeronResolveEmailreturningconst 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; thepick?.verifiedguard matters becauseonResolveEmailcan only ever return a verified address, so returning an unverifiedpickwould 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 returnsundefinedfor 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+jwtin their JWT header (RFC 9068 §2.1), closing a gapdocs/mcp-oauth-conformance.mdtracked from the 2026-07-28 conformance pass (#202).withMCPAuth's production verifier (verifyAccessTokenWithKeySet) now enforces the header (RFC 9068 §4: the resource server MUST verifytyp), acceptingat+jwtand the prior defaultJWT— a token already minted withtyp: JWTkeeps verifying rather than being invalidated outright; since every token minted from here on carriestyp: at+jwt, theJWTallowance 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 pinnedtyp: JWTon these tokens must acceptat+jwtgoing forward.
Fixed
- An unpinned Azure shared-alias provider on the older v1 keys shape (
.../common/discovery/keys, nov2.0— Azure's actual v1 metadatajwks_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'ssts.windows.netissuer can never equal a probedlogin.microsoftonline.comancestor), and the resulting log told the operator to pinissuerexplicitly — 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 (seesrc/lib/DESIGN.md) exists to prevent. Unpinned, this shape is now byte-identical tomain, exactly like the v2 alias. Pinningissueron 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: pointjwksUriat 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 tojwksUri'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 comparesauthorizationUrl's segment againstjwksUri'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'sconsumersalias andjwksUri's fixedAZURE_CONSUMERS_TENANT_IDGUID. - 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 andindex.ts's session-validation middleware) callbuildProviderConfigwithout alogger— 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 withcacheDynamicProvidersset to a short window orfalse).clear()resets this dedup state along with the rest of the cache's per-provider tracking. /loginnow redirects with a reason code when CSRF state storage fails, instead of letting the raw error escape (#227):handleLoginawaitedgenerateCSRFToken→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.handleCallbackalready follows this pattern for its own failures (e.g. the account-adoption gate'saccount_lookup_failed) rather than letting a raw error escape;/loginnow matches it: agenerateCSRFTokenrejection is logged server-side and redirects tooriginalUrl(sanitized by the samebuildErrorRedirectthe other reason codes use) witherror=server_error&reason=state_storage, with noSet-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
issuerpin (see the Changed entry above) is now skipped with a logged error at startup, instead of aborting every other declared provider (#264).initializeProviderscallsbuildProviderConfigfor 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" andredirectUrichecks, 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 otherbuildProviderConfigfailures 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 Azureissuerpin 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).
clearOAuthSessionpreviously returned as if it had succeeded whenever an existing session's.updatewasn't callable — silently leaving the stored session'soauth/userin place while the caller believed it was cleared. It now returnsfalsein that case, when.updateitself 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 asfalserather than escaping as a rejection — while still keeping its intentional quiet success for the no-session and anonymous-logout (noid) cases.handleLogoutnow returns503withCache-Control: no-storeinstead of a200success 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 avalidateAndRefreshSessionresult reporting the same failure): a request whose invalidation couldn't persist now gets the same503/no-storeinstead of continuing to serve it as the stale, supposedly-revoked identity.withOAuthValidation'srequireAuth: falsepass-through is unaffected by design: it already proceeds on any invalidation, persisted or not. Separately, the token-refresh-failure path's own secondclearOAuthSessioncall (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 resolvingvalidateAndRefreshSession'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 agenericprovider withjwksUriset — but noissuerleftissuerValidatedalwaysfalse(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 atissuer. Config building for a provider declared inproviders:now throws at startup, naming the provider and theissuerkey, whenever it hasjwksUriset but no usableissuer(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 forissuer. Okta/Auth0'sdomainshortcut (and Azure'stenantIdshortcut) already deriveissuerfor you — this only affects a provider bypassing that shortcut with explicit endpoints. Azure's multi-tenant/commondefault is unaffected: it shipsissuer: nulldeliberately (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 setsjwksUriand noissuer— Okta/Auth0/genericonly, not Azure — now fails to start; the same config applied via a live reload is rejected and the previous configuration keeps serving. Setissuerexplicitly, or use thedomain/tenantIdshortcut. Superseded by #264, below (same release): anhttpsauthorizationUrlno longer hard-fails here at all — it derivesissuervia background OIDC discovery instead; only a non-httpsauthorizationUrlstill fails fast as described above. - UserInfo
submust match the ID token'ssubon thefetchEmailfallback path (#231 §5a, OIDC Core 5.3.2/5.3.4): when an ID token lacks anemailclaim andfetchEmailis configured, the plugin fetches the UserInfo endpoint to resolve it. A UserInfo response whosesubdiffers from (or omits, which OIDC Core 5.3.2 also disallows) the verified ID token'ssubdescribes 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 asubmismatch 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 taggedunauthenticated, 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/meUserInfo shape (the Azure preset's default) is correlated byid/oidinstead ofsub, so the Azure preset'sfetchEmail: truebehavior is unaffected — provided the id token carriesoid, which requires theprofilescope (the preset's defaultscopeincludes it; a config that overridesscopeto dropprofilelosesoidand falls back to the discard above — #264, below, adds a startup warning for exactly this combination). - A transient
hdb_userread error during account adoption now quarantines the login instead of denying it, unlessallowUnverifiedClaimInheritanceis 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 anyhdb_username, 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 transienthdb_userread 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
allowUnverifiedClaimInheritanceis 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. _emailProvenanceis no longer stampedsigned-oidcon the no-JWKS fallback (#231 §5d):OAuthProvider.getUserInfolabeled any presentidTokenClaimssigned-oidcregardless 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-checkssignatureVerified/issuerValidatedbefore trustingsigned-oidc), but the label overstated its own contract for any other consumer.getUserInfonow takes the verified-signature flag and only stampssigned-oidcwhen it istrue.- Token error responses are now tested for
Cache-Control: no-store/Pragma: no-cache. The error path (token.ts:errorResponse) already applied the sameNO_STORE_HEADERSas 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
jwksUriuses the?appid=<client-id>form of the shared-alias keys URL (custom signing keys) with explicit endpoints and notenantIdare not recognized as Azure aliases, and fail at startup with an issuer-discovery error when unpinned. Workaround: settenantId, or use the tenant-specific keys URL. Fix tracked in #275.