Repository navigation
feat: drop session on tenant change - #20
Merged
Merged
Conversation
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>
mderriey
approved these changes
Sep 4, 2026
There was a problem hiding this comment.
🟡 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.
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>
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.
copySessionJwtToBearerHeadercopies 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
authorizationUrlRequestOverridevaries 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-esmcarries ~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, afteraugmentSessionso 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.
copySessionJwtToBearerHeaderis 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 tellscookie-sessionto unset the session, and its getter then answersnull— socreateEnsureAuthenticatedHandler'sif (!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 /graphqland gatesGET, so the two never meet. An app serving both an API and browser pages does put them in one request, andgraphql-template-esmis 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-session2.1.1 over a real request:{}req.session = {}{}req.session = nullnullcookie-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, soexpect(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, sincetoEqual({})passes for both.logoutkeepsreq.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 deletesdropSessionOnTenantChange, its seven tests,augmentSession'stenantId— that middleware is its only reader — oneapp.use, and a comment explaining thecookie-sessiontrap this PR moves into the library where it belongs.🤖 Generated with Claude Code