Skip to content

Let components authenticate their own Bearer tokens as Harper users (server.registerBearerValidator) - #3313

Merged
kriszyp merged 7 commits into
mainfrom
feat/bearer-validator-hook
Oct 21, 2026
Merged

kriszyp merged 7 commits into
mainfrom
feat/bearer-validator-hook

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 20, 2026 •

Copy link
Copy Markdown
Member
Gate robot puzzling over a winged ticket

⊙ 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 /mcp answer 401.

🤔 Should core own this, rather than the plugin authenticating its own requests?

Yes, as specified: one place decides request.user for 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.
  • A validator returns null, { username, authExpiresAt?, authCacheable? }, or throws credentialRejectionError. server.getUser resolves the user, so its own role applies.
  • Harper's tokens are checked first. No validators on a dedicated operations port, and the token cannot be exchanged for Harper tokens.
  • Logins are cached until authExpiresAt, capped at the JWT exp (default 5 minutes). Live subscriptions and MQTT wills end then.
server.registerBearerValidator(async (token, request, signal) => {
	const claims = await verifyMyAccessToken(token, { signal }); // null for tokens this issuer did not sign
	if (!claims) return null;
	if (claims.aud !== MY_AUDIENCE) throw credentialRejectionError('wrong audience', 401);
	return { username: claims.sub, authExpiresAt: claims.exp };
});

🤔 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
  • Plugin middleware — every plugin re-implements precedence, the minting block and expiry.
  • Issue Harper operation tokens — no RFC 8707 audience binding, which MCP requires.
  • Fallback in validateOperationToken — no request there, and operations-port auth calls it too.

🤔 Username only, or also per-token permissions?

Username only; any other field, role included, 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. Refusing role keeps 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 --> U
Loading

The validator contract

What may a validator return, and what does Harper do with it?

A validator returns null, { username, authExpiresAt?, authCacheable? }, or throws. Any other field fails closed.
The username is resolved by server.getUser(username, null, request), with the Basic path's record tracking.
A tagged throw is a credential rejection; an untagged throw, a malformed result or a timeout is a 401 in place.

Caching and expiry

How long does an accepted token stay accepted?

Registration lifecycle and token minting

Who can register, and what can a validator-authenticated user not do?

MQTT last wills

Can a will outlive the token that authorized it?

Docs: Document server.registerBearerValidator

Tests and fixtures

✅ Verification

  • Integration (REST, /mcp tools/list, GraphQL, WebSocket) and security suites: 45/45. Unit: 155 passing.
  • The two expiry rechecks (after the server.getUser lookup, after will replay) each have a test that fails without the fix.
  • Full local gates pass, apart from two blob tests that pass alone and in CI.
Gate details
  • test:unit:main: 7330 passing; 2 failing in blobMsgpackSerialization.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 are record-caching-scan-coherence, whose server was not up in time under load; 3/3 alone.
- PR QA: skipped @ 44ee648 — dispatch unavailable (no worker token in the agent's home)

🤖 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

  • 258b53374ec1279b minor correctness — components/mcp/listChanged.ts — non-scoped MCP session refresh via findAndValidateUser yields role-less user for custom getUser principals — NOT FILED
  • 68be2bfdb6d78f3b minor correctness — resources/Resource.ts — live-subscription recheck via findAndValidateUser revokes custom-getUser-only validator principals — NOT FILED
  • 0c33e36d230f384b nit maintainability — server/Server.ts — default validator timeout 5 s instead of 30 s — NOT FILED
  • 714610fa6104ed27 nit 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 #3267
  • af8801a2b5517bc7 nit performance — security/user.ts — record provenance per userView and cloneUserView on cache hits — NOT FILED
  • 1882fe06c54011d3 nit security — components/Scope.ts — validator registered via module-level harper server bypasses Scope rollback and closed guard — NOT FILED
  • a6a257702252e7a8 nit reliability — security/auth.ts — authorizationCache accumulates rotated external Bearer tokens — NOT FILED
  • abd015a7d4414ec8 nit reliability — components/Scope.ts — validator registrations outlive scope.close() and would accumulate on an in-process reload — NOT FILED
  • 1b6ae28adda30edb nit security — server/DurableSubscriptionsSession.ts — restart replay of non-scoped will skips revocation checks the live path applies — NOT FILED
  • 12e79a86e3c23afb nit reliability — security/bearerValidators.ts — synchronous validator work is not charged to timeoutMs — NOT FILED
  • +5 more in the review receipt

Dispatch: task harper-oauth279-bearer-validator-hook · queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-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

kriszyp and others added 4 commits October 19, 2026 20:41
…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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces 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).

Comment thread server/Server.ts Resolved
kriszyp and others added 3 commits October 19, 2026 21:58
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
kriszyp marked this pull request as ready for review October 21, 2026 13:59
@kriszyp
kriszyp merged commit e187b83 into main Oct 21, 2026
71 of 72 checks passed
@kriszyp
kriszyp deleted the feat/bearer-validator-hook branch October 21, 2026 14:00
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant