Repository navigation
Let components authenticate their own Bearer tokens as Harper users (server.registerBearerValidator) - #3313
Merged
Conversation
…gisterBearerValidator)
A component registers a validator for Bearer tokens Harper's own operation and refresh tokens
reject. The validator returns null to decline, { username, authExpiresAt?, authCacheable? } to
authenticate as that Harper user (resolved through server.getUser), or throws a tagged credential
rejection. Any other field (role included), an untagged throw, or exceeding timeoutMs fails closed.
- security/bearerValidators.ts: ordered chain with per-validator and chain-wide deadlines
(AbortSignal), expiry capped by the token's own future JWT exp (default 300 s, rechecked after
the server.getUser lookup), record tracking as on the Basic path.
- security/auth.ts: runs the chain after the refresh probe on every port but a dedicated
operations port; caches by Authorization header unless authCacheable is false; evicts a cached
principal when its validator is disposed; defers the invalid-token warnings until no validator
claims the token.
- Registrations are rolled back when an application fails to load (Scope, componentLoader).
- _bearerValidator principals cannot mint operation, refresh or scoped tokens, including after
impersonation; MQTT wills persist the token expiry and are dropped past it.
- server/DESIGN.md documents the hook in the authentication section.
Dispatch-Task: harper-oauth279-bearer-validator-hook
Co-Authored-By: Gemini <noreply@google.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
…nly component users on Basic Framing-recheck findings on the username-only design (chosen-approach-sound c5a29c4ae64f): - Restart replay of an MQTT will rehydrated the user through server.getUser before publishing, so a lookup that outlasted the bearer token published past expiry. The per-will replay is now replayLastWill(), which checks the persisted expiry again after the lookup. - Basic authentication copied every resolved user before caching; it now copies only a user a component's server.getUser returned (trackUserRecords reports that), the object a memoizing override can hand to later lookups. - The hanging server.getUser test has its own deadline; DESIGN.md notes that a server.getUser override stays authoritative. Dispatch-Task: harper-oauth279-bearer-validator-hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
…ode enforces Round 20 review rulings: - Rollback covers registrations made through the scope.server a plugin's handleApplication receives; a module's own `server` import is the application's shared server and keeps its registrations, like its server.http middleware. JSDoc, DESIGN.md say so. - timeoutMs bounds waiting (it cannot interrupt synchronous work), and the claim's server.getUser lookup shares the chain budget. - DESIGN.md no longer claims recheck parity: live-subscription rechecks and MCP refreshes re-read hdb_user by name, as they already do for a Basic login through a server.getUser override. - Trimmed narrating comments; guarded the ROOTPATH restore in gitSSHMaterialization teardown. Dispatch-Task: harper-oauth279-bearer-validator-hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
Closing-round nits: the abort-listener and mid-lookup disposal tests now fail on their own deadline instead of hanging under .mocharc.json's disabled timeout, and three comments that restated the code are gone. Dispatch-Task: harper-oauth279-bearer-validator-hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
This was referenced Oct 20, 2026
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism for registering custom Bearer token validators via server.registerBearerValidator, allowing Harper to authenticate external tokens. It integrates these validators into the core authentication flow, handles exact token expiration (including for MQTT last-wills), enforces timeout budgets, and prevents passwordless token minting. Feedback on the changes highlights a potential bug in server/Server.ts where disposing of a validator could accidentally remove the last active validator if the entry is not found in the array due to splice(-1, 1).
The design note claimed validator principals cannot mint tokens at all. createTokens refuses them a passwordless operation/refresh mint and, unless operation authorization is bypassed, a scoped token; minting with a username and password still works. Dispatch-Task: harper-oauth279-bearer-validator-hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
…g its directory On Windows, deleting a directory a Scope still watches raises an uncaught EPERM from the watcher, which failed the Windows unit leg. The test now closes the scope its failing plugin received before removing the temp app directory. Dispatch-Task: harper-oauth279-bearer-validator-hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
The decoded JWT exp is unverified, so it may only shorten a validator principal's lifetime. A past exp used to be ignored for the lifetime (the principal lived for authExpiresAt or 300 s, uncached), so a validator that checked only the signature authenticated expired tokens. A past exp now shortens the lifetime to nothing: the token is refused as `token expired`, a tagged rejection. The API is unreleased, so no accepted token changes behavior. Dispatch-Task: harper-oauth279-bearer-validator-hook Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
kriszyp
marked this pull request as ready for review
October 21, 2026 13:59
kriszyp
added a commit
to HarperFast/documentation
that referenced
this pull request
Oct 22, 2026
Reference for the component Bearer-validator hook added in HarperFast/harper#3313: the result contract, expiry and caching, rejection and timeout behavior, and an example. The overview's authentication paragraph links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
kriszyp
added a commit
to HarperFast/documentation
that referenced
this pull request
Oct 22, 2026
… release note - The authExpiresAt entry states the one-year ceiling, the 5-minute default, and that a JWT whose exp has passed is refused (HarperFast/harper#3313 now does this) while the validator must still verify expiry itself. - timeoutMs distinguishes the per-call limit from the per-request deadline the validators and the user lookup share. - The JWT guide notes that a Bearer-validator caller cannot mint tokens without a password; the security overview links the hook; 5.4 release notes gain an Authentication entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
kriszyp
added a commit
to HarperFast/oauth
that referenced
this pull request
Oct 22, 2026
…e request.user (#279) (#287) * feat(mcp): register core Bearer validator and populate request.user (#279) - Register the plugin's MCP access-token verifier with Harper core auth via scope.server.registerBearerValidator (HarperFast/harper#3313) when available, returning { username: payload.sub } on valid MCP access tokens, abstaining (null) when MCP is disabled, no signing keys exist, or the token is not an MCP JWT, and throwing credentialRejectionError('Invalid token', 'Bearer') on invalid MCP access tokens. - Log a one-time warning when mcp.enabled is true and server.registerBearerValidator is absent on older Harper builds. - Populate request.user alongside request.mcp in withMCPAuth, resolving via server.getUser(sub, null, request) when available, failing closed with 401 on rejected/inactive users, and preserving roleless quarantine principals (unverified:<claim>#<hash>) without calling getUser. Fixes #279 Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): address cross-model review findings on bearerValidator and withMCPAuth - Share verifyMCPAccessToken and pathOwned between withMCPAuth and bearerValidator - Return undefined synchronously on non-owned tokens and cache resolved issuer/resource - Enforce route path prefix in bearerValidator so /mcp tokens cannot authenticate non-MCP routes - Register bearer validator lazily when mcp.enabled is true and dispose on disable/close - Re-check getConfig()?.enabled after async awaits in withMCPAuth and bearerValidator - Bind server.getUser, verify existing request.user matches sub, and return 503 on transient user-store errors - Align docs, changelog, and conformance matrix with implementation and Harper #3313 Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): address planning review Round 1 and add #279 integration tests - Prefer request.originalPathname before request.pathname in getRequestPathname so native /mcp subroutes after stripPrefix(request, '/mcp') still resolve /mcp - Harden pathOwned against dot-segments, backslashes, NULs, and percent-encoded path separators (%2f, %5c, %00) - Enforce pathOwned before key lookup and without emitting oauth.mcp.token.rejected in bearerValidator - Return authCacheable: false on accepted bearerValidator results and guard void disposer / missing credentialRejectionError - Bound server.getUser with a 5s timeout and only populate request.user in withMCPAuth when resolvedUser has a plain-object role.permission - Add integration tests in integrationTests/mcp-runtime.test.ts exercising withMCPAuth request.user population and end-to-end native /mcp tools/list and tools/call against Harper #3313 Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): address round 2 cross-model review findings (#279) - Clamp authExpiresAt in createMCPBearerValidator to at most 365 days in the future so Harper's effectiveExpiry upper bound never rejects long-lived tokens - Remove custom path-traversal segment checks from withMCPAuth so default-group middleware always chains to next(request) on unowned paths - Detect Harper credential-rejection errors by Symbol('harper.credentialRejection') as well as error.credentialRejection and include CORS headers on 503 responses - Clear unregisterBearerValidator only after dispose() succeeds and emit the missing-hook warning whenever registerMCPBearerValidator returns undefined - Align docs/mcp-oauth.md, docs/mcp-oauth-conformance.md, and CHANGELOG.md with current behavior and exact test titles Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): fix onLogin doc shape and round 3 review nits (#279) - Fix Option A onLogin example in docs/mcp-oauth.md to return { user: '<harper-username>' } matching handlers.ts - Align cross-component getConfig resource example with default <issuer>/mcp audience - Replace empty catch blocks in bearerValidator.ts with explicit returns/throws - Clarify missing-hook warning in src/index.ts to include harper.credentialRejectionError Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): fail closed on untagged getUser errors and clarify docs (#279) Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): abstain on off-path bearer tokens and tighten withMCPAuth path/next guards (#279) - Return undefined synchronously in createMCPBearerValidator when the request path is outside mcp.resource's path prefix so other registered Bearer validators sharing the app-origin issuer can run on non-MCP routes; fail closed with credentialRejectionError when mcp.resource is not a valid URL instead of falling back to /mcp - Match withMCPAuth's path option against request.pathname (preserving mount-relative matching when Harper sets originalPathname) while keeping originalPathname for the 2048-char DoS guard - Clear a populated request.user if a default-group withMCPAuth handler mistakenly calls next(request) - Clear unregisterBearerValidator in a finally block on disposal - Align harper test mocks with Harper's Symbol('harper.credentialRejection') contract and honor HARPER_BIN_PATH / REQUIRE_BEARER_VALIDATOR in integrationTests/mcp-runtime.test.ts Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): memoize verified bearer tokens and validate mcp.resource at startup (#279) Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): harden bearer validator path checks, reload disposal, and key-ref memoization (#279) Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> * fix(mcp): coalesce keyStore reads, distinguish transient errors, and harden memo/config (#279) Dispatch-Task: oauth-279-use-bearer-validator Co-Authored-By: Gemini <noreply@google.com> --------- Co-authored-by: Gemini <noreply@google.com>
This was referenced Oct 23, 2026
kriszyp
added a commit
to HarperFast/documentation
that referenced
this pull request
Oct 26, 2026
Reference for the component Bearer-validator hook added in HarperFast/harper#3313: the result contract, expiry and caching, rejection and timeout behavior, and an example. The overview's authentication paragraph links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
kriszyp
added a commit
to HarperFast/documentation
that referenced
this pull request
Oct 26, 2026
… release note - The authExpiresAt entry states the one-year ceiling, the 5-minute default, and that a JWT whose exp has passed is refused (HarperFast/harper#3313 now does this) while the validator must still verify expiry itself. - timeoutMs distinguishes the per-call limit from the per-request deadline the validators and the user lookup share. - The JWT guide notes that a Bearer-validator caller cannot mint tokens without a password; the security overview links the hook; 5.4 release notes gain an Authentication entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
kriszyp
added a commit
that referenced
this pull request
Oct 27, 2026
- server/fastifyRoutes.ts: keep v5.3's .js helper imports; import only authenticationChallengeForError, the one symbol the error handler uses. - deferred-credential-rejection integration test: threads.count 1 from #3558; drop main's mcp mountPath, which belongs to #3313's MCP test, not on v5.3. - fixture resources.js: take #3558's getUser counter and blocked user; keep v5.3's exported session-user constants. - auth-fastify.test.js: take #3558's requires; drop main-only env, CONFIG_PARAMS and setTokenTimeout, which nothing on v5.3 uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA9rEVL9D9fhv2oAZfe Dispatch-Task: cherry-resolve-kriszyp_harper_3558-fe0b01ea
kriszyp
added a commit
that referenced
this pull request
Oct 30, 2026
…d of re-checking it, and every authentication 401 carries a challenge (conflicts → v5.3) (#3620) * Settle deferred credential rejections on legacy Fastify routes and challenge every authentication 401 hdbCore.preValidation re-ran authentication for a credential the middleware had already rejected and deferred, then fell through to passport: two server.getUser calls, a third verification against hdb_user (accepting a credential a component's server.getUser had refused), a doubled Session audit event, and a generic "Must login" body. authorize() now settles the recorded rejection. In-place and settled authentication 401s now carry a negotiated Content-Type and, on the application port, a WWW-Authenticate challenge (Bearer for a Bearer credential, Basic realm="Harper" otherwise); the legacy Fastify server challenges the 401s it renders. Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Carry the deferred strategy's challenge onto a settled legacy rejection The legacy error handler inferred the challenge from the presented Authorization header, so an mTLS rejection beside an unevaluated Bearer header was called an invalid token. authorize() also settles a rejection that its own authentication run deferred, instead of falling through to passport. The uncredentialed 401 path challenges by the presented scheme. Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Claim invalid_token only for a Bearer token Harper rejected A tagged rejection of something else (a body password) no longer tells an RFC 6750 client to discard a good token; the authorize() Bearer branch marks the token rejections it makes. Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Settle a deferred rejection before the Bun pre-auth branch in authorize() Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Name Bun beside uWS in the legacy settlement scope Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Fit the challenge note in server/DESIGN.md's line budget after the rebase Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Carry the challenge non-enumerably and pin it in the authorize() tests Round-6 review nits: the carried challenge uses defineProperty like the credential-rejection tag; both authorize() settle tests assert the challenge the legacy error handler derives (an mTLS deferral keeps Basic under a presented Bearer scheme); the settlement paragraph names the shared descriptor's challenge; three restating comments removed. Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Settle a deferred rejection before trusting req.raw.user in authorize() Closing-round review: authorize() returned req.raw.user before checking the recorded rejection, the reverse of the settle-before-reading-user order other route owners follow, so a user an application middleware set after a deferral ran the legacy route. The inner settle in the re-run callback was redundant with the re-entry check and is removed. A unit test pins that an internal fault in authorize()'s own authentication run fails closed rather than reaching passport. Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Assert the fault path's challenge claims no rejected token Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Group the deferral test imports with the other requires after the rebase Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Take a legacy route's in-place rejection message from its record, not the negotiated body authorize() parsed the in-place 401 body as JSON, but that body is serialized for the request's negotiated content type. A CBOR or MessagePack Accept made JSON.parse throw inside the promise callback, so next() never ran and the legacy request hung. The message now comes from the in-place rejection record (Node and Bun branches); the fault unit test sends Accept: application/cbor. Dispatch-Task: harper-fastify-deferred-rejection-401 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwywihpL2C475j7qSp78N4 * Fit the challenge note in server/DESIGN.md's budget after rebasing onto main Dispatch-Task: pr-maint-c977f7fb4d95459c4a6adb85238b3dea Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Resolve the #3558 cherry-pick conflicts onto v5.3 - server/fastifyRoutes.ts: keep v5.3's .js helper imports; import only authenticationChallengeForError, the one symbol the error handler uses. - deferred-credential-rejection integration test: threads.count 1 from #3558; drop main's mcp mountPath, which belongs to #3313's MCP test, not on v5.3. - fixture resources.js: take #3558's getUser counter and blocked user; keep v5.3's exported session-user constants. - auth-fastify.test.js: take #3558's requires; drop main-only env, CONFIG_PARAMS and setTokenTimeout, which nothing on v5.3 uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA9rEVL9D9fhv2oAZfe Dispatch-Task: cherry-resolve-kriszyp_harper_3558-fe0b01ea --------- Co-authored-by: Kris Zyp <kriszyp@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp
added a commit
to HarperFast/documentation
that referenced
this pull request
Nov 2, 2026
Reference for the component Bearer-validator hook added in HarperFast/harper#3313: the result contract, expiry and caching, rejection and timeout behavior, and an example. The overview's authentication paragraph links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
kriszyp
added a commit
to HarperFast/documentation
that referenced
this pull request
Nov 2, 2026
… release note - The authExpiresAt entry states the one-year ceiling, the 5-minute default, and that a JWT whose exp has passed is refused (HarperFast/harper#3313 now does this) while the validator must still verify expiry itself. - timeoutMs distinguishes the per-call limit from the per-request deadline the validators and the user lookup share. - The JWT guide notes that a Bearer-validator caller cannot mint tokens without a password; the security overview links the hook; 5.4 release notes gain an Authentication entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
Ethan-Arrowood
pushed a commit
to HarperFast/documentation
that referenced
this pull request
Nov 2, 2026
* Document server.registerBearerValidator Reference for the component Bearer-validator hook added in HarperFast/harper#3313: the result contract, expiry and caching, rejection and timeout behavior, and an example. The overview's authentication paragraph links to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg * Address docs review: JWT expiry, shared deadline, minting exceptions, release note - The authExpiresAt entry states the one-year ceiling, the 5-minute default, and that a JWT whose exp has passed is refused (HarperFast/harper#3313 now does this) while the validator must still verify expiry itself. - timeoutMs distinguishes the per-call limit from the per-request deadline the validators and the user lookup share. - The JWT guide notes that a Bearer-validator caller cannot mint tokens without a password; the security overview links the hook; 5.4 release notes gain an Authentication entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg * Narrow the scoped-token, operations-port and abort wording Scoped minting stays refused for a Bearer-validator caller even with a password; the release note limits the hook to the application port; the abort applies only to a validator still running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg * Split the unknown-user rejection into its own sentence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg * Bound the Bearer-validator cache by authentication.cacheTTL The authorization cache is also flushed every authentication.cacheTTL (30 s by default), so a cached validator result lasts until authExpiresAt or that flush, whichever comes first. Say so, and spell out the revocation consequence: new requests see a revoked token refused within one cacheTTL period, while live subscriptions run until authExpiresAt. Dispatch-Task: fix-kriszyp_documentation_737-edd13dfe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tighten Bearer-validator limits, throws and the scoped-mint rule - Bound revocation by cacheTTL plus an in-flight validator call, since a call already running when the cache is flushed caches its result in the new period. - Say an over-a-year authExpiresAt fails the request and an out-of-range timeoutMs throws at registration, rather than reading as clamped. - Say any throw ends the validator chain, so a validator returns null for tokens another issuer may own. - Move the create_authentication_tokens restriction out of the timeoutMs paragraph and include the scoped-token refusal; carry that refusal into the operations reference. Dispatch-Task: fix-kriszyp_documentation_737-edd13dfe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Widen the revocation bound to the whole in-progress authentication; give rejected results their 401 outcome Dispatch-Task: fix-kriszyp_documentation_737-edd13dfe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * List component Bearer validators beside the authentication methods Dispatch-Task: fix-kriszyp_documentation_737-edd13dfe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
Components that issue their own access tokens, like the OAuth plugin's MCP authorization server (MCP OAuth does not populate context.user for Harper core's native /mcp endpoint), cannot use them on Harper's routes. Core authentication accepts only Harper's own Bearer tokens and defers any other (Defer unrecognized app-port Authorization until route ownership), so REST, GraphQL, WebSocket, MQTT and
/mcpanswer 401.🤔 Should core own this, rather than the plugin authenticating its own requests?
Yes, as specified: one place decides
request.userfor every route. Plugin middleware would re-implement token precedence, the minting block and cache expiry.🤔 Ship from main (v5.4), not as a v5.3 backport?
Main: it is a new public API. The review suggested v5.3 only because it extends will code backported there.
💡 Solution
server.registerBearerValidator(validator, { timeoutMs })returns a function that removes it.null,{ username, authExpiresAt?, authCacheable? }, or throwscredentialRejectionError.server.getUserresolves the user, so its own role applies.authExpiresAt, capped at the JWTexp(default 5 minutes). Live subscriptions and MQTT wills end then.🤔 Is caching by the Authorization header the right default?
A cached login is reused on every route without asking the validator, so a route-dependent validator must return
authCacheable: false(documented). Keying by route would cost every login.⚖️ Alternatives
Other approaches
validateOperationToken— no request there, and operations-port auth calls it too.🤔 Username only, or also per-token permissions?
Username only; any other field,
roleincluded, fails. OAuth plugin tokens name a Harper user and carry no permissions, while a role-carrying login needs its own revocation wherever users are re-read by name. Refusingrolekeeps adding it later compatible.🔧 Changes
Product and architecture tour
Product and architecture tour
Where validators run
How does a Bearer token Harper did not issue become
request.user?Bearer token path through authentication()
Harper's own tokens are tried first. Validators only see a token those reject, and a claimed username still goes through
server.getUser.flowchart TD A[Bearer header] --> B{cached and unexpired} B -- yes --> U[request.user] B -- no --> C{Harper operation token} C -- valid --> U C -- expired --> R[credential rejection] C -- invalid --> D{refresh token} D -- yes --> O[operations API handles it] D -- no --> E{validators registered, not ops port} E -- no --> F[deferred as before] E -- yes --> G[validators in order] G -- all decline --> F G -- tagged throw --> R G -- other throw or timeout --> X[401 in place] G -- username --> H[server.getUser, expiry recheck] H --> Usubisrefresh; refresh tokens are always signed with that subject, so other JWTs skip a second RSA verify.The validator contract
What may a validator return, and what does Harper do with it?
runBearerValidatorsruns validators in registration order and refuses any field outside the three (the allowed set).principalFromClaimresolves the user throughresolveUser. An unknown username or a user with no role is a tagged rejection, like an unknown Basic user.withinDeadlinebounds each validator by itstimeoutMsand the whole chain, lookup included, by the largest one. On a timeout it rejects before aborting the signal, so an abort listener that resolves cannot win.BearerValidatorIdentityandBearerValidatorOptions, exported fromharper. The JSDoc onregisterBearerValidatoris the contract.Caching and expiry
How long does an accepted token stay accepted?
effectiveExpirytakes the validator'sauthExpiresAt(now + 300 s when omitted), capped by the token's own JWTexp, so a JWT whoseexphas passed is refused astoken expiredeven when the validator accepts it. It is checked again after the lookup.authorizationCachestays keyed by the Authorization header. A cached validator principal is evicted once its validator is disposed, viavalidatorClaims, so registering or disposing never flushes Basic or session logins.UsergainsauthExpiresAt.trackUserRecordsnow reports whether the user came from a component override, and the Basic path copies only those before caching, so a memoizing override's object cannot be re-stamped under a cached login.Registration lifecycle and token minting
Who can register, and what can a validator-authenticated user not do?
server.registerBearerValidatorvalidatestimeoutMs(max 60 s), publishes a frozen snapshot read bygetBearerValidators, and returns a disposer that marks the entry inactive.scope.server;disposeBearerValidatorsremoves them when the plugin's load fails, and a closed scope refuses new ones. Registrations otherwise outlivescope.close()so in-flight requests drain._bearerValidatoruser cannot mint operation or refresh tokens without a password or, unless operation authorization is bypassed, scoped tokens; the marker survives impersonation.server/DESIGN.mdrecords these rules, the rejection table row and the refresh-probe ordering.MQTT last wills
Can a will outlive the token that authorized it?
authExpiresAt, andisPastBearerExpirydrops it on live disconnect.replayLastWill, which checks the expiry again after itsserver.getUserlookup.Docs: Document server.registerBearerValidator
Tests and fixtures
authCredentialDeferral.test.js: the validator suite, covering malformed results, header-keyed caching, will expiry and replay and expiry during the lookup. The existing deferral suite only seeds system tables first.deferred-credential-rejection.test.tsruns a real read-only user over REST, MCP (with the MCP mount enabled), GraphQL and WebSocket, and the operations port; it creates that user. The fixture registers the validators; its session-user names became module-private.Scope.test.jscovers proxy registration, close and failed-load rollback.gitSSHMaterialization.test.jsnow restoresROOTPATH; it leaked into later suites.✅ Verification
/mcptools/list, GraphQL, WebSocket) and security suites: 45/45. Unit: 155 passing.server.getUserlookup, after will replay) each have a test that fails without the fix.Gate details
test:unit:main: 7330 passing; 2 failing inblobMsgpackSerialization.test.js, which registers REST on the default mount and gets no handler when an earlier suite already has. It passes alone and in this PR's CI.test:unit:resources: 4407 passing, 0 failing.test:integration:all: 2548 passing, 0 failing; 9 cancelled. Six are the local Ollama suite (no models on this machine). Three arerecord-caching-scan-coherence, whose server was not up in time under load; 3/3 alone.🤖 Generated by Claude Code (Claude Opus 5.5); posted via @kriszyp.
https://claude.ai/code/session_01FKif56wJ3LB5ZqsGi3E9eg
Release target: main — new API surface, ships in v5.4
Related PRs: #2906 overlaps (both edit the Bearer and deferral branch of security/auth.ts), 1 others independent
Complexity: complicated
Deferred findings
258b53374ec1279bminor correctness —components/mcp/listChanged.ts— non-scoped MCP session refresh via findAndValidateUser yields role-less user for custom getUser principals — NOT FILED68be2bfdb6d78f3bminor correctness —resources/Resource.ts— live-subscription recheck via findAndValidateUser revokes custom-getUser-only validator principals — NOT FILED0c33e36d230f384bnit maintainability —server/Server.ts— default validator timeout 5 s instead of 30 s — NOT FILED714610fa6104ed27nit correctness —security/user.ts— nested in-place permission write on a getUser view mutates the shared derivedRoles memo — filed security/user: userView shallow-copies role.permission so nested in-place edits mutate shared derivedRoles memo #3267af8801a2b5517bc7nit performance —security/user.ts— record provenance per userView and cloneUserView on cache hits — NOT FILED1882fe06c54011d3nit security —components/Scope.ts— validator registered via module-level harper server bypasses Scope rollback and closed guard — NOT FILEDa6a257702252e7a8nit reliability —security/auth.ts— authorizationCache accumulates rotated external Bearer tokens — NOT FILEDabd015a7d4414ec8nit reliability —components/Scope.ts— validator registrations outlive scope.close() and would accumulate on an in-process reload — NOT FILED1b6ae28adda30edbnit security —server/DurableSubscriptionsSession.ts— restart replay of non-scoped will skips revocation checks the live path applies — NOT FILED12e79a86e3c23afbnit reliability —security/bearerValidators.ts— synchronous validator work is not charged to timeoutMs — NOT FILEDDispatch: task
harper-oauth279-bearer-validator-hook· queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse,muse; rounds=22; full=8 @ 8194686
Review-Attention: deep ~45m (sensitive: auth.ts, bearerValidators.ts +3; hot: DurableSubscriptionsSession.ts; decisions: jwt-exp-hard-cap, expiry-check-after-validator, validators-inside-authentication, cache-keyed-by-header, ops-exclusion-dedicated-port-only, do-less-alternative, release-target-unclear) @ 8194686