Skip to content

feat: drop session on tenant change - #20

Merged
cuzzlor merged 4 commits into
mainfrom
own-every-reason-a-session-cannot-pass
Sep 4, 2026
Merged

cuzzlor merged 4 commits into
mainfrom
own-every-reason-a-session-cannot-pass

Conversation

@cuzzlor

@cuzzlor cuzzlor commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

copySessionJwtToBearerHeader copies a session's token to the authorization header, and drops the session instead where that token must not be used — leaving no header, so an interactive middleware mounted after it signs the visitor in again.

It already did that for one reason: an expired token. There is a second.

The gap

An app whose authorizationUrlRequestOverride varies the authority per request holds sessions belonging to the authority they signed in at and serves requests asking for another — a tenant on the URL, typically. Nothing compared the two, so the session passed straight through, the override never ran, and such a request did nothing for exactly the people most likely to follow a link: those already signed in.

Consumers have been working around it with a middleware of their own, mounted ahead of this one. graphql-template-esm carries ~25 lines of it plus seven tests, one of which exists only to pin the mount order — because a check placed after the copy can never re-authenticate anything.

The change

createCopySessionJwtToBearerHeader({ authorizationUrlRequestOverride, logger }) drops a session whose authority no longer matches what the override returns for this request, beside the expiry drop. The authority is carried onto the authenticated session by the reply handler, after augmentSession so an app cannot displace the value being compared.

Keyed on the authority, not on a tenant: the library needs no tenant list, no parameter name, and no knowledge that tenants exist, and it works for any dimension a consumer varies the authority on.

Nothing breaks. copySessionJwtToBearerHeader is the same middleware with no override, an app passing none takes only the expiry drop, and so does a session written before the authority was recorded. The override is called inside the promise chain, so one that throws synchronously is caught rather than escaping the handler, and a failure copies the token as it did before the check existed.

A bug fixed while here

The expiry drop set req.session = null. That tells cookie-session to unset the session, and its getter then answers null — so createEnsureAuthenticatedHandler's if (!req.session) throw Error('Express session is not available') fires instead of starting the sign-in the drop exists to cause.

It bites where an app mounts this middleware ahead of the interactive one in the same request, which the README's own example does not do — it copies on POST /graphql and gates GET, so the two never meet. An app serving both an API and browser pages does put them in one request, and graphql-template-esm is one; there, a browser whose interactive session had expired got a 500 rather than a re-login, which is the wedge #16 set out to remove, relocated.

The drop has to satisfy two things at once, and there is exactly one way to do it. Verified against cookie-session 2.1.1 over a real request:

drop style reads back in the same request cookie
delete its keys {} rewritten as an empty session
req.session = {} {} not written at all — the stale cookie survives
req.session = null null cleared

cookie-session saves a session only when it is populated or was read from a cookie, and has changed. A fresh {} is neither populated nor read from a cookie, so it is neither saved nor removed. Emptying the session already read is both readable for the rest of the request and written back, so the next request reads it as unauthenticated and signs in again.

The spec passed a plain object as req.session, so expect(req.session).toBeNull() held while the integration did not. It now asserts the session is the same object, emptied — which is what tells replacement from mutation, since toEqual({}) passes for both. logout keeps req.session = null: removal is the right semantics there, and it responds immediately, so nothing reads the session afterwards.

Tests

21 passing. Seven new cases cover the authority drop: a differing authority, a matching one, an async override, an override naming no authority, a session from before this release, a throwing override, and an expired token dropping before the override is ever asked.

The gap I did not close: each spec still builds its own session fixture, so nothing proves the login and reply handlers agree on the key name. Worth a test that runs one into the other, separately from this.

Worth deciding

The expiry fix is a behaviour change independent of the feature, and could ship on its own. Say so and I will split it.

Downstream

graphql-template-esm #10 then deletes dropSessionOnTenantChange, its seven tests, augmentSession's tenantId — that middleware is its only reader — one app.use, and a comment explaining the cookie-session trap this PR moves into the library where it belongs.

🤖 Generated with Claude Code

cuzzlor and others added 3 commits September 4, 2026 18:16
copySessionJwtToBearerHeader is where that decision is made. The
interactive middleware skips any request already carrying an
authorization header, so once this middleware copies a token the login
gate never runs — which makes it, and not the gate, the place that
decides whether a session still passes.

It already made that call for one reason, an expired token. There is a
second: an app whose authorizationUrlRequestOverride varies the authority
per request has sessions belonging to the authority they signed in at and
requests asking for another. Nothing compared them, so such a request was
a no-op for anyone already signed in — most people following a link —
and consumers worked around it with a middleware of their own, mounted
ahead of this one, whose whole correctness rested on that order.

createCopySessionJwtToBearerHeader takes the override and drops a session
whose authority no longer matches, beside the expiry drop. The authority
is carried onto the authenticated session by the reply handler, after
augmentSession so the library's own value wins. An app that passes no
override, or a session written before this release, takes neither branch,
and copySessionJwtToBearerHeader is the same middleware with no override.

The override is called inside the promise chain, so one that throws
synchronously is caught rather than escaping the handler, and a failure
copies the token as it did before this check existed.

Fixes a bug in the expiry drop while it is here: it set `req.session =
null`, which tells cookie-session to unset the session, and its getter
then answers null — so createEnsureAuthenticatedHandler threw 'Express
session is not available' instead of starting the sign-in the drop exists
to cause. An empty session drops the token just as well and leaves an
object to read. The spec passed a plain object as the session, so the
assertion held while the integration did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two claims held only under a wiring this library does not document. The
interactive middleware does not skip a request carrying an authorization
header — an app gates it that way, as the new README example now shows,
and it is that wiring under which a copied token suppresses re-login and
a null session reaches the login gate in the same request.

The type comment named copySessionJwtToBearerHeader as the thing that
compares the authority, which it never does: it carries no override.

And a session written before the authority was recorded was said to take
"neither branch", when the expiry drop applies to it like any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…back

The drop had to satisfy two things at once and the first attempt at this
satisfied one of them.

`req.session = null` removes the cookie, but the getter then answers null
and an app mounting this ahead of the interactive middleware gets
'Express session is not available' thrown at it — the bug this branch set
out to fix.

Replacing it with a fresh `{}` leaves an object to read, but cookie-session
saves a session only when it is populated or was read from a cookie, *and*
has changed. A new empty object is neither populated nor read from a
cookie, so it is neither saved nor removed: the browser keeps the stale
cookie and sends it again on the next request.

Deleting the keys of the session already read does both. Verified against
cookie-session 2.1.1 over a real request: emptied in place it reads back
`{}` and the cookie is rewritten as an empty session; replaced it reads
back `{}` and no Set-Cookie is written at all.

The test now asserts the session is the same object, emptied, which is
what tells the two apart — `toEqual({})` alone passes for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cuzzlor
cuzzlor requested a review from mderriey September 4, 2026 11:11
@cuzzlor cuzzlor changed the title feat: own every reason a session may not stand in for a sign-in feat: drop session on tenant change Sep 4, 2026
@cuzzlor
cuzzlor requested a balanced review from Copilot September 4, 2026 12:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new generic session field can collide with existing consumer-defined data, and the documented example is incomplete for supported configurations.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds authority-aware session invalidation so tenant changes trigger reauthentication, while correcting expired-session clearing behavior.

Changes:

  • Records and compares the login authority.
  • Empties invalid sessions in place.
  • Adds tests, documentation, and a minor version bump.
File summaries
File Description
src/index.ts Implements authority-aware session dropping.
src/copy-session-jwt-to-bearer-header.spec.ts Tests expiry and authority handling.
README.md Documents the new middleware factory.
package.json Bumps the version to 2.3.0.
package-lock.json Synchronizes the package version.
Review details
  • Files reviewed: 4/5 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/index.ts
Comment thread README.md
Comment thread README.md Outdated
Copilot review on #20 found two real faults in the new section:

- the snippet used `createCopySessionJwtToBearerHeader` without showing where
  it comes from
- `/*splat` is Express 5 syntax, and the package supports `express` >= 4. On
  Express 4 it compiles to `^\/(.*)splat\/?$`, which matches only paths that
  end in the literal text, so the interactive middleware never runs

The example now uses `*` with the same Express 5 note the first example
carries.

It also asked for a namespaced session key. Declined: `isAuthenticated`,
`accessToken`, `originalUrl` and `pkceCodes` are all plainly named, and
`authority` shipped under this name on the PKCE session in 2.2.0. The prose
now says the library owns the key and writes it after `augmentSession`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cuzzlor
cuzzlor merged commit cfec8d5 into main Sep 4, 2026
1 check passed
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.

3 participants