@lenne.tech/nest-server 11.42.1 → 11.42.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +3 -2
- package/.claude/rules/role-system.md +31 -3
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +43 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +219 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +2 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.js +54 -18
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.js +19 -0
- package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +2 -1
- package/dist/core/modules/better-auth/better-auth.config.js +27 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +4 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +20 -8
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.js +2 -2
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +1 -0
- package/dist/core/modules/tus/core-tus.service.js +39 -0
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +11 -6
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core.module.d.ts +3 -0
- package/dist/core.module.js +100 -42
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/migration-guides/11.42.1-to-11.42.2.md +53 -0
- package/migration-guides/11.42.2-to-11.42.3.md +145 -0
- package/package.json +1 -1
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +382 -0
- package/src/core/common/helpers/graphql-ws-context.helper.ts +91 -18
- package/src/core/common/interfaces/server-options.interface.ts +32 -2
- package/src/core/modules/auth/services/core-auth.service.ts +37 -0
- package/src/core/modules/better-auth/better-auth.config.ts +46 -1
- package/src/core/modules/better-auth/core-better-auth.service.ts +9 -1
- package/src/core/modules/tenant/README.md +8 -8
- package/src/core/modules/tenant/core-tenant.guard.ts +41 -12
- package/src/core/modules/tus/README.md +15 -0
- package/src/core/modules/tus/core-tus.controller.ts +4 -2
- package/src/core/modules/tus/core-tus.service.ts +60 -0
- package/src/core/modules/user/core-user.service.ts +19 -6
- package/src/core.module.ts +166 -55
- package/src/index.ts +1 -0
|
@@ -208,6 +208,7 @@ JWT-based authentication for existing projects:
|
|
|
208
208
|
| **Custom Scalars** | `Date`, `DateTime` (timestamp), `JSON`, `Any` |
|
|
209
209
|
| **Subscriptions** | WebSocket support via `graphql-ws` with auth. The `PUB_SUB` provider is built from a factory: `CoreRedisPubSub` when `redis` is configured (delivery is then cluster-wide), the in-memory `PubSub` otherwise (delivery only to clients connected to the publishing replica). **Constraint once Redis is in play: every published payload must be JSON-serializable** — it crosses the wire as JSON, so `Date`, class instances, `Map`/`Set` and `undefined` do not survive the round trip. An in-process `PubSub` never had this constraint, so a payload that worked on one replica can silently lose fields on a cluster. Publish plain objects and ISO strings |
|
|
210
210
|
| **Subscription request context** | Since 11.35.0 `CoreModule` installs a context-aware `execute` / `subscribe` pair (inside `subscriptions`, where `ApolloDriver` forwards it) on all three GraphQL driver builders, so every WebSocket operation runs inside a `RequestContext`. Before that a WS operation had **none** — no Express middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a subscription context — and `mongooseTenantPlugin` reads "no context" as "system operation, no filter", so a tenant-scoped read while delivering a subscription message returned EVERY tenant's rows. The tenant comes from the handshake's tenant header **validated against an active membership**, else from the subscriber's memberships; an unresolvable tenant leaves the safety net to refuse the read. The async iterator is wrapped too, not only the subscribe call: graphql-js runs `resolve` / `filter` / field resolvers inside its `next()`. See `.claude/rules/role-system.md` → "Tenant context on non-HTTP transports" |
|
|
211
|
+
| **Subscription re-check** | Since 11.42.3 that context is not trusted for the life of the socket. Every connection a `CoreModule` `onConnect` accepts is registered (`registerWsConnection()`) and checked again — at once on an invalidation (tenant guard, role change, password reset, Better-Auth deleting the session), otherwise before the next operation or event once `graphQl.subscriptionRevalidationMs` (30 s) has passed. The check re-runs the handshake's authentication and the tenant resolution; a connection whose rights changed is closed with 4403 (retried by the graphql-ws client, which then reconnects with its current rights) and the event that triggered the check is dropped. Before, a removed membership, a deactivated tenant, a withdrawn role or a revoked session kept receiving events on an open socket until the client reconnected. See `.claude/rules/role-system.md` → "Open WebSockets" |
|
|
211
212
|
| **Complexity Analysis** | Query cost calculation to prevent DoS attacks |
|
|
212
213
|
| **Enum Registration** | `registerEnum()` helper for GraphQL enum types |
|
|
213
214
|
| **Upload Support** | `graphqlUploadExpress()` for multipart file uploads |
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Migration Guide: 11.42.1 → 11.42.2
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | ------------------------------------------------------------------------ |
|
|
7
|
+
| **Bugfix** | Finished TUS uploads answer 204 behind the `compression` middleware (§1) |
|
|
8
|
+
| **Migration Effort** | None — `pnpm update` |
|
|
9
|
+
|
|
10
|
+
## Quick Migration
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm update @lenne.tech/nest-server
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Vendor-mode projects pick up the core update; nothing else to change.
|
|
17
|
+
|
|
18
|
+
## 1. A finished TUS upload never got an answer although the file was stored
|
|
19
|
+
|
|
20
|
+
`@tus/server` finishes its responses with `res.end(callback)`, a documented Node signature. The
|
|
21
|
+
`compression` middleware patches `res.end(chunk, encoding)` and treats the first argument as a chunk,
|
|
22
|
+
so the callback reached `Buffer.byteLength()` and threw:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer … Received function
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The starter's `main.ts` — and therefore every generated project — registers `compression` with
|
|
29
|
+
`filter: () => true` and `threshold: 0`, which removes the two checks that would otherwise skip a
|
|
30
|
+
bodyless 204. The failure hit the final `PATCH`, AFTER the upload had been migrated into the file
|
|
31
|
+
store: the request hung — the headers were already sent, so no error response could follow — the
|
|
32
|
+
client never saw a 2xx, `tus-js-client` never fired `onSuccess`, and no log named `compression`.
|
|
33
|
+
|
|
34
|
+
Since 11.42.2 `CoreTusService` normalises `res.end(callback)` before the tus server writes
|
|
35
|
+
(`normalizeEndCallback()`, wrapped around `getServer().handle()`), so it holds for the core controller
|
|
36
|
+
and for any project controller that calls the tus server. No change to `main.ts` is needed; a project
|
|
37
|
+
that worked around it by excluding `/tus` from the compression filter can keep or drop that filter.
|
|
38
|
+
|
|
39
|
+
## 2. TUS request errors are logged with their stack
|
|
40
|
+
|
|
41
|
+
`CoreTusController` logged only `error.message` for a failed TUS request. A `TypeError` thrown by a
|
|
42
|
+
middleware's patched `res.end` then read like a tus failure, with nothing pointing at the middleware —
|
|
43
|
+
the defect in §1 took several rounds to find for exactly that reason. The stack is now logged too.
|
|
44
|
+
|
|
45
|
+
## Troubleshooting
|
|
46
|
+
|
|
47
|
+
| Symptom | Cause | Fix |
|
|
48
|
+
| -------------------------------------------------------------------------------------- | ------------------------------------- | ----------------- |
|
|
49
|
+
| File stored, final PATCH never answers (request hangs), `Received function` in the log | 11.42.1 or older behind `compression` | Update to 11.42.2 |
|
|
50
|
+
|
|
51
|
+
## Module Documentation
|
|
52
|
+
|
|
53
|
+
- [TUS README](../src/core/modules/tus/README.md) — Troubleshooting
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Migration Guide: 11.42.2 → 11.42.3
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| **Security** | An open GraphQL WebSocket loses what its user lost — membership, tenant, role, session — instead of keeping it until reconnect (§1) |
|
|
8
|
+
| **Security** | IAM sign-out ends the session on the server, not only in the browser (§2) |
|
|
9
|
+
| **Bugfix** | The IAM WebSocket handshake accepts a valid bearer token (§3) |
|
|
10
|
+
| **Security** | The legacy WebSocket handshake verifies the token's signature and expiry, as HTTP does (§4) |
|
|
11
|
+
| **New option** | `graphQl.subscriptionRevalidationMs` (default 30000) (§1) |
|
|
12
|
+
| **Migration Effort** | None for most projects — `pnpm update`. Check §1 if you replaced `onConnect` yourself |
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Vendor-mode projects pick up the core update. No configuration change is required.
|
|
21
|
+
|
|
22
|
+
## 1. Open WebSockets are re-checked, and closed when the rights behind them changed
|
|
23
|
+
|
|
24
|
+
A GraphQL WebSocket was authorized once: `onConnect` validated the token and recorded the user, the
|
|
25
|
+
subscribe wrapper resolved the tenant, and every event and operation on the socket ran on that
|
|
26
|
+
snapshot until the client reconnected. Removing someone from a tenant, switching a tenant off,
|
|
27
|
+
withdrawing a role or ending a session took effect on every new request and on none of the sockets
|
|
28
|
+
that were already open — for a browser tab, often hours.
|
|
29
|
+
|
|
30
|
+
Since 11.42.3 every connection `CoreModule`'s `onConnect` accepts is registered with the rights it was
|
|
31
|
+
authorized with, and checked again:
|
|
32
|
+
|
|
33
|
+
| Trigger | When |
|
|
34
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
35
|
+
| `CoreTenantGuard.invalidateUser()` / `invalidateTenant()` / `invalidateAll()`, `CoreUserService.setRoles()`, an `update()` of `roles` / `verified` / `emailVerified`, the legacy password reset | at once |
|
|
36
|
+
| Better-Auth deleting a session: sign-out, a revoked session, a reset with `revokeSessionsOnPasswordReset` | at once |
|
|
37
|
+
| A change nobody invalidated (a membership edited directly in the database, an expired session) | before the next event or operation, once `graphQl.subscriptionRevalidationMs` has passed |
|
|
38
|
+
|
|
39
|
+
A connection whose rights changed is closed with **4403**. The graphql-ws client retries that code: it
|
|
40
|
+
reconnects, resubscribes and continues with the rights the user holds now, or is refused at the
|
|
41
|
+
handshake when the session is gone. Nothing changes for a connection whose rights did not change.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// config.env.ts — optional, the default is 30000
|
|
45
|
+
graphQl: {
|
|
46
|
+
subscriptionRevalidationMs: 30000, // 0 = re-check on every event and operation, false = only on invalidation
|
|
47
|
+
},
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**If you replaced `onConnect`** through `graphQl.driver.subscriptions`, register what you accept, or
|
|
51
|
+
your connections keep the old "checked once" behaviour:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { registerWsConnection } from '@lenne.tech/nest-server';
|
|
55
|
+
|
|
56
|
+
onConnect: async (context) => {
|
|
57
|
+
const user = await authenticate(context); // your own handshake
|
|
58
|
+
context.extra.user = user;
|
|
59
|
+
registerWsConnection({
|
|
60
|
+
carrier: context.extra,
|
|
61
|
+
reauthenticate: () => authenticate(context), // the user the token belongs to NOW, or null
|
|
62
|
+
socket: context.extra.socket,
|
|
63
|
+
user,
|
|
64
|
+
});
|
|
65
|
+
return context.extra;
|
|
66
|
+
},
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**If your client does not use graphql-ws** (or turned its retries off), make sure it reconnects after a
|
|
70
|
+
4403 close; otherwise the user's live updates stop until the page is reloaded.
|
|
71
|
+
|
|
72
|
+
A dropped event is checked after graphql-js executed it and before it is sent: the reads made while
|
|
73
|
+
computing it happened, but nothing of it reaches the client. Subscription resolvers should not write.
|
|
74
|
+
|
|
75
|
+
## 2. IAM sign-out ends the session on the server
|
|
76
|
+
|
|
77
|
+
`CoreBetterAuthService.revokeSession()` — used by `POST /iam/sign-out` and the `betterAuthSignOut`
|
|
78
|
+
mutation — passed the session token to Better-Auth's `signOut` only as an `Authorization` header.
|
|
79
|
+
Better-Auth reads that header only with its `bearer` plugin, which the framework does not install. The
|
|
80
|
+
sign-out therefore answered `{ success: true }`, cleared the cookies, and deleted nothing: the session
|
|
81
|
+
stayed valid server-side, and the same token kept authenticating requests until it expired.
|
|
82
|
+
|
|
83
|
+
The token is now passed as the signed session cookie Better-Auth reads, and the header is kept for
|
|
84
|
+
projects that added the bearer plugin themselves. **No action is needed.** Sessions signed out before
|
|
85
|
+
the update are still valid until they expire; if that matters for your deployment, delete them from the
|
|
86
|
+
`session` collection.
|
|
87
|
+
|
|
88
|
+
## 3. The IAM WebSocket handshake accepts a valid bearer token
|
|
89
|
+
|
|
90
|
+
Both IAM driver builders authenticated the handshake through `getSession()` with an `Authorization`
|
|
91
|
+
header — the same header Better-Auth ignores without its `bearer` plugin. Every IAM WebSocket handshake
|
|
92
|
+
with a bearer token was refused as `Invalid or expired session`, so authenticated subscriptions did not
|
|
93
|
+
work in IAM-only projects at all.
|
|
94
|
+
|
|
95
|
+
The handshake now resolves the token the way the HTTP middleware does for the same header: a Better-Auth
|
|
96
|
+
JWT first, then the session store, and `getSession()` as the last resort. If you built a workaround
|
|
97
|
+
(for example a custom `onConnect`), you can remove it — and register the connection as shown in §1 if
|
|
98
|
+
you keep it.
|
|
99
|
+
|
|
100
|
+
## 4. The legacy WebSocket handshake verifies the token
|
|
101
|
+
|
|
102
|
+
Legacy access tokens are verified on HTTP by passport-jwt: signature and expiry. The legacy WebSocket
|
|
103
|
+
handshake read them with `decodeJwt()` instead, which checks neither — only the payload's `tokenId` had
|
|
104
|
+
to match the device. An expired token, or a copy of one re-signed with any key, therefore kept opening
|
|
105
|
+
sockets for as long as the device's refresh token was not rotated.
|
|
106
|
+
|
|
107
|
+
The handshake, and the re-check from §1, now use `CoreAuthService.verifyJwt()`, which applies passport's
|
|
108
|
+
rule: `jwt.secretOrKeyProvider` when configured, else `jwt.secretOrPrivateKey || jwt.secret`. A token
|
|
109
|
+
that is not a JWT at all is refused as unauthorized instead of closing the socket with 4500 and a
|
|
110
|
+
TypeError as the reason.
|
|
111
|
+
|
|
112
|
+
**What this changes for a legacy WebSocket client:** a connection now ends when its token does — at
|
|
113
|
+
expiry, or when the client refreshes its tokens (a refresh rotates the device's `tokenId`, which already
|
|
114
|
+
invalidated the old token on HTTP). The socket is closed with 4403 and graphql-ws reconnects, so pass
|
|
115
|
+
the CURRENT token on every connect rather than the one the client had at start-up:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
createClient({
|
|
119
|
+
url,
|
|
120
|
+
connectionParams: () => ({ Authorization: `Bearer ${getCurrentAccessToken()}` }), // a function, not an object
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The same holds for an IAM client that authenticates its socket with a Better-Auth JWT (§3): the JWT
|
|
125
|
+
expires after `betterAuth.jwt.expiresIn` (15 minutes by default). A session token lives as long as its
|
|
126
|
+
session.
|
|
127
|
+
|
|
128
|
+
An AuthService passed to `CoreModule.forRoot()` that does not extend `CoreAuthService` may lack
|
|
129
|
+
`verifyJwt()`; it keeps the unverified read and logs a warning once. Extend `CoreAuthService` to close it.
|
|
130
|
+
|
|
131
|
+
## Troubleshooting
|
|
132
|
+
|
|
133
|
+
| Symptom | Cause | Fix |
|
|
134
|
+
| -------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------- |
|
|
135
|
+
| A client's WebSocket closes with 4403 after a role or membership change | Intended: the rights behind the connection changed | Let the client reconnect (graphql-ws does by default) |
|
|
136
|
+
| Live updates stop after a role change and never resume | The client does not reconnect after a 4403 close | Enable reconnects (graphql-ws `retryAttempts`) |
|
|
137
|
+
| Revoked access still receives events on a project with its own `onConnect` | The custom `onConnect` does not register the connection | Call `registerWsConnection()` (§1) |
|
|
138
|
+
| IAM subscriptions are refused with `Invalid or expired session` | 11.42.2 or older | Update to 11.42.3 |
|
|
139
|
+
| A WebSocket reconnects and is refused shortly after a token refresh or expiry | The client reconnects with the token it had at start-up | Pass `connectionParams` as a function (§4) |
|
|
140
|
+
| `has no verifyJwt()` warning at the first legacy WebSocket | The AuthService does not extend `CoreAuthService` | Extend `CoreAuthService` (§4) |
|
|
141
|
+
|
|
142
|
+
## Module Documentation
|
|
143
|
+
|
|
144
|
+
- [Tenant README](../src/core/modules/tenant/README.md) — Deactivated tenants
|
|
145
|
+
- [Better-Auth README](../src/core/modules/better-auth/README.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.42.
|
|
3
|
+
"version": "11.42.3",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { ConfigService } from '../services/config.service';
|
|
4
|
+
import { getTenantContextResolver, ResolvedTenantContext } from '../services/core-tenant-context.registry';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The authorization lifecycle of an OPEN GraphQL WebSocket connection.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS: a WebSocket is authorized at the handshake. `onConnect` validates the session and
|
|
10
|
+
* records the user; the subscribe wrapper (`graphql-ws-context.helper.ts`) resolves the tenant. Before
|
|
11
|
+
* 11.42.3 nothing ever looked again — every delivered event and every further operation on the socket
|
|
12
|
+
* ran on that snapshot. Removing somebody from a tenant, switching a tenant off, withdrawing a role or
|
|
13
|
+
* ending a session therefore took effect on every NEW request and on none of the sockets that were
|
|
14
|
+
* already open, for as long as the client kept them open. For a browser tab that is hours.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT DOES: each connection is registered with the rights it was authorized with — the user's id,
|
|
17
|
+
* roles and verification state, and the tenant scope its operations resolved — plus a function that
|
|
18
|
+
* re-runs the handshake's authentication. Those rights are checked again
|
|
19
|
+
*
|
|
20
|
+
* - when an INVALIDATION names the connection (`revalidateWsConnectionsOf()` from a membership or
|
|
21
|
+
* role change, `revalidateWsConnectionsForTenant()` from a tenant switch, a password reset), at
|
|
22
|
+
* once and before any further event, and
|
|
23
|
+
* - before an event is delivered or an operation runs, once the last check is older than
|
|
24
|
+
* `graphQl.subscriptionRevalidationMs` (default 30 s) — the bound for a change nobody invalidated,
|
|
25
|
+
* such as a session that was signed out or a membership edited directly in the database.
|
|
26
|
+
*
|
|
27
|
+
* A connection whose rights CHANGED — narrowed or widened — is closed with 4403. It is not patched in
|
|
28
|
+
* place, deliberately: a subscription may have chosen its topic or its filter from the tenant at
|
|
29
|
+
* subscribe time, and only a fresh subscribe re-derives that. The graphql-ws client RETRIES 4403 (its
|
|
30
|
+
* own comment: "might grant access after retry"), so the user reconnects and continues with the
|
|
31
|
+
* rights they hold now, or is refused at `onConnect` if the session is gone.
|
|
32
|
+
*
|
|
33
|
+
* WHAT IT DOES NOT DO: an event is checked AFTER graphql-js executed it and BEFORE it is sent. Reads
|
|
34
|
+
* made while computing a dropped event still happened — under the old context — but nothing of them
|
|
35
|
+
* reaches the client. Subscription resolvers that WRITE are outside what this can take back.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Close code for a connection whose authorization changed: graphql-ws's `CloseCode.Forbidden`. Its
|
|
40
|
+
* client retries it, which is what makes the close a re-authorization rather than a hang-up.
|
|
41
|
+
*/
|
|
42
|
+
export const WS_AUTHORIZATION_CHANGED_CLOSE_CODE = 4403;
|
|
43
|
+
|
|
44
|
+
/** Default for `graphQl.subscriptionRevalidationMs`. */
|
|
45
|
+
export const DEFAULT_SUBSCRIPTION_REVALIDATION_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
/** The parts of a socket this helper needs: closing it, and noticing that it closed. */
|
|
48
|
+
export interface IWsConnectionSocket {
|
|
49
|
+
close(code: number, reason?: string): void;
|
|
50
|
+
once?(event: 'close', listener: (...args: any[]) => void): unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IWsConnectionRegistration {
|
|
54
|
+
/**
|
|
55
|
+
* The object every operation of the connection receives as its GraphQL context: `extra` for
|
|
56
|
+
* graphql-ws, the object `onConnect` returns for subscriptions-transport-ws (which copies it per
|
|
57
|
+
* operation — the registration survives that copy).
|
|
58
|
+
*/
|
|
59
|
+
carrier: object;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Re-run the handshake's authentication. Resolves to the user the credential authenticates as NOW,
|
|
63
|
+
* or to a falsy value when it no longer does. A rejection counts as falsy. Without it only the
|
|
64
|
+
* tenant scope is re-checked.
|
|
65
|
+
*/
|
|
66
|
+
reauthenticate?: () => any;
|
|
67
|
+
|
|
68
|
+
/** The raw socket — closed when the rights changed, and watched so a closed connection is forgotten. */
|
|
69
|
+
socket?: IWsConnectionSocket;
|
|
70
|
+
|
|
71
|
+
/** The user the handshake authenticated. */
|
|
72
|
+
user: any;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Who an invalidation is about. Any one field is enough; `userId` is the `users` document id. */
|
|
76
|
+
export interface IWsConnectionIdentity {
|
|
77
|
+
email?: string;
|
|
78
|
+
iamId?: string;
|
|
79
|
+
userId?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @internal The registered state of one connection. */
|
|
83
|
+
export interface WsConnection {
|
|
84
|
+
readonly email?: string;
|
|
85
|
+
readonly iamId?: string;
|
|
86
|
+
readonly reauthenticate?: () => any;
|
|
87
|
+
readonly socket?: IWsConnectionSocket;
|
|
88
|
+
/** The tenant scope the connection's operations resolved, once one has. */
|
|
89
|
+
tenant?: ResolvedTenantContext;
|
|
90
|
+
/** The tenant header the client sent — constant for the life of the connection. */
|
|
91
|
+
readonly tenantHeader?: string;
|
|
92
|
+
terminated: boolean;
|
|
93
|
+
readonly user: any;
|
|
94
|
+
readonly userFingerprint: string;
|
|
95
|
+
readonly userId?: string;
|
|
96
|
+
/** @internal */
|
|
97
|
+
inflight?: Promise<boolean>;
|
|
98
|
+
/** @internal */
|
|
99
|
+
stale: boolean;
|
|
100
|
+
/** @internal */
|
|
101
|
+
tenantFingerprint?: string;
|
|
102
|
+
/** @internal */
|
|
103
|
+
validatedAt: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Module-private and not `Symbol.for()`: nothing a client sends — `connectionParams` are JSON — can
|
|
108
|
+
* produce it, so a registration cannot be forged or overwritten from outside. Enumerable on purpose:
|
|
109
|
+
* subscriptions-transport-ws hands each operation an `Object.assign` copy of the connection context,
|
|
110
|
+
* and only enumerable own properties survive that.
|
|
111
|
+
*/
|
|
112
|
+
const WS_CONNECTION = Symbol('ltWsConnection');
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Connections that can be found by an invalidation. Only those with a socket to watch: without a
|
|
116
|
+
* `close` event there is no moment to forget them, and a registry that only grows is a leak. A
|
|
117
|
+
* connection without one is still re-checked by the interval, through its carrier.
|
|
118
|
+
*/
|
|
119
|
+
const liveConnections = new Set<WsConnection>();
|
|
120
|
+
|
|
121
|
+
const logger = new Logger('GraphQlWsConnection');
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve `graphQl.subscriptionRevalidationMs` to an interval, or `false` for "off".
|
|
125
|
+
*
|
|
126
|
+
* Only an explicit `false` switches the interval off. Anything else that is not a usable interval
|
|
127
|
+
* falls back to the DEFAULT, never to "off": this bounds how long revoked access keeps flowing, and a
|
|
128
|
+
* typo in an environment variable must not be the thing that removes the bound. `0` is a legitimate
|
|
129
|
+
* value meaning "on every operation and every event".
|
|
130
|
+
*/
|
|
131
|
+
export function resolveSubscriptionRevalidationMs(value: unknown): false | number {
|
|
132
|
+
if (value === false || value === 'false') {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const numeric = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
136
|
+
if (typeof numeric === 'number' && Number.isFinite(numeric) && numeric >= 0) {
|
|
137
|
+
return numeric;
|
|
138
|
+
}
|
|
139
|
+
return DEFAULT_SUBSCRIPTION_REVALIDATION_MS;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function configuredRevalidationMs(): false | number {
|
|
143
|
+
const graphQl = ConfigService.configFastButReadOnly?.graphQl;
|
|
144
|
+
return resolveSubscriptionRevalidationMs(graphQl ? graphQl.subscriptionRevalidationMs : undefined);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What a connection's rights depend on, as a comparable string. Role ORDER does not count. */
|
|
148
|
+
function fingerprintUser(user: any): string {
|
|
149
|
+
if (!user) {
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
const roles = Array.isArray(user.roles) ? user.roles.map((role: unknown) => String(role)).sort() : [];
|
|
153
|
+
const verified = !!(user.verified || user.verifiedAt || user.emailVerified);
|
|
154
|
+
return JSON.stringify([String(user.id ?? user._id ?? ''), roles, verified]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fingerprintTenant(tenant: ResolvedTenantContext | undefined): string {
|
|
158
|
+
const tenantIds = Array.isArray(tenant?.tenantIds) ? tenant.tenantIds.map((id) => String(id)).sort() : null;
|
|
159
|
+
return JSON.stringify([tenant?.tenantId ?? null, tenantIds, tenant?.tenantRole ?? null, !!tenant?.isAdminBypass]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Same lookup the context wrapper uses — see `readTenantHeader()` in graphql-ws-context.helper.ts. */
|
|
163
|
+
export function readWsTenantHeader(contextValue: any): string | undefined {
|
|
164
|
+
const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
|
|
165
|
+
const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
|
|
166
|
+
for (const source of sources) {
|
|
167
|
+
if (!source || typeof source !== 'object') {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
|
|
171
|
+
if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Register a connection after a successful handshake. Call it from `onConnect`, with the same user
|
|
181
|
+
* the handshake recorded on the carrier.
|
|
182
|
+
*/
|
|
183
|
+
export function registerWsConnection(registration: IWsConnectionRegistration): void {
|
|
184
|
+
const { carrier, reauthenticate, socket, user } = registration;
|
|
185
|
+
if (!carrier || typeof carrier !== 'object') {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const userId = user?.id ?? user?._id;
|
|
189
|
+
const connection: WsConnection = {
|
|
190
|
+
email: typeof user?.email === 'string' ? user.email.toLowerCase() : undefined,
|
|
191
|
+
iamId: typeof user?.iamId === 'string' ? user.iamId : undefined,
|
|
192
|
+
reauthenticate,
|
|
193
|
+
socket,
|
|
194
|
+
stale: false,
|
|
195
|
+
tenantHeader: readWsTenantHeader(carrier),
|
|
196
|
+
terminated: false,
|
|
197
|
+
user,
|
|
198
|
+
userFingerprint: fingerprintUser(user),
|
|
199
|
+
userId: userId !== undefined && userId !== null ? String(userId) : undefined,
|
|
200
|
+
validatedAt: Date.now(),
|
|
201
|
+
};
|
|
202
|
+
Object.defineProperty(carrier, WS_CONNECTION, { configurable: true, enumerable: true, value: connection });
|
|
203
|
+
|
|
204
|
+
if (socket && typeof socket.once === 'function') {
|
|
205
|
+
liveConnections.add(connection);
|
|
206
|
+
socket.once('close', () => {
|
|
207
|
+
connection.terminated = true;
|
|
208
|
+
liveConnections.delete(connection);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The registered connection a GraphQL context belongs to, if any. */
|
|
214
|
+
export function getWsConnection(contextValue: any): undefined | WsConnection {
|
|
215
|
+
if (!contextValue || typeof contextValue !== 'object') {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const connection = contextValue[WS_CONNECTION];
|
|
219
|
+
return connection && typeof connection === 'object' ? connection : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Close a connection whose authorization no longer holds. Idempotent; always answers `false` so a
|
|
224
|
+
* caller can `return terminate(...)`.
|
|
225
|
+
*/
|
|
226
|
+
function terminate(connection: WsConnection, reason: string): false {
|
|
227
|
+
if (connection.terminated) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
connection.terminated = true;
|
|
231
|
+
liveConnections.delete(connection);
|
|
232
|
+
logger.log(`Closing GraphQL WebSocket of user ${connection.userId ?? '(unknown)'}: ${reason}`);
|
|
233
|
+
try {
|
|
234
|
+
// A close reason is limited to 123 bytes on the wire; these are short and ASCII.
|
|
235
|
+
connection.socket?.close(WS_AUTHORIZATION_CHANGED_CLOSE_CODE, reason);
|
|
236
|
+
} catch {
|
|
237
|
+
// Already closing — the outcome is the same.
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function revalidate(connection: WsConnection): Promise<boolean> {
|
|
243
|
+
connection.stale = false;
|
|
244
|
+
try {
|
|
245
|
+
let user = connection.user;
|
|
246
|
+
if (connection.reauthenticate) {
|
|
247
|
+
user = await connection.reauthenticate();
|
|
248
|
+
if (!user) {
|
|
249
|
+
return terminate(connection, 'Session no longer valid');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (fingerprintUser(user) !== connection.userFingerprint) {
|
|
253
|
+
return terminate(connection, 'User authorization changed');
|
|
254
|
+
}
|
|
255
|
+
if (connection.tenantFingerprint !== undefined) {
|
|
256
|
+
const resolver = getTenantContextResolver();
|
|
257
|
+
const tenant = resolver ? await resolver.resolve(user, connection.tenantHeader) : {};
|
|
258
|
+
if (fingerprintTenant(tenant) !== connection.tenantFingerprint) {
|
|
259
|
+
return terminate(connection, 'Tenant access changed');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (connection.terminated) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
connection.validatedAt = Date.now();
|
|
266
|
+
return true;
|
|
267
|
+
} catch {
|
|
268
|
+
// Fail closed: a check that cannot be made is not a check that passed. The client reconnects,
|
|
269
|
+
// and the handshake decides again.
|
|
270
|
+
return terminate(connection, 'Authorization check failed');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Is the connection still authorized as it was registered? Re-checks when an invalidation marked it,
|
|
276
|
+
* or when its last check is older than `graphQl.subscriptionRevalidationMs`; otherwise answers from
|
|
277
|
+
* the last check without any I/O. Concurrent callers share one re-check.
|
|
278
|
+
*
|
|
279
|
+
* `false` means the connection has been closed and nothing more may be delivered or executed on it.
|
|
280
|
+
*/
|
|
281
|
+
export function ensureWsConnectionCurrent(connection: WsConnection): Promise<boolean> {
|
|
282
|
+
if (connection.terminated) {
|
|
283
|
+
return Promise.resolve(false);
|
|
284
|
+
}
|
|
285
|
+
const interval = configuredRevalidationMs();
|
|
286
|
+
if (!connection.stale && (interval === false || Date.now() - connection.validatedAt < interval)) {
|
|
287
|
+
return Promise.resolve(true);
|
|
288
|
+
}
|
|
289
|
+
if (!connection.inflight) {
|
|
290
|
+
connection.inflight = revalidate(connection).finally(() => {
|
|
291
|
+
connection.inflight = undefined;
|
|
292
|
+
// An invalidation that arrived while this check was running may have been answered from data
|
|
293
|
+
// that predates it — check once more rather than lose it.
|
|
294
|
+
if (connection.stale && !connection.terminated) {
|
|
295
|
+
void ensureWsConnectionCurrent(connection);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return connection.inflight;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Record the tenant scope an operation on this connection resolved.
|
|
304
|
+
*
|
|
305
|
+
* The first resolution becomes the connection's scope. A later operation resolving a DIFFERENT one
|
|
306
|
+
* means the rights changed underneath the subscriptions that are already running on the old scope —
|
|
307
|
+
* the connection is closed, and `false` returned.
|
|
308
|
+
*/
|
|
309
|
+
export function noteWsConnectionTenant(connection: WsConnection, tenant: ResolvedTenantContext | undefined): boolean {
|
|
310
|
+
if (connection.terminated) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const fingerprint = fingerprintTenant(tenant);
|
|
314
|
+
if (connection.tenantFingerprint === undefined) {
|
|
315
|
+
connection.tenant = tenant ? { ...tenant } : {};
|
|
316
|
+
connection.tenantFingerprint = fingerprint;
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
if (fingerprint !== connection.tenantFingerprint) {
|
|
320
|
+
return terminate(connection, 'Tenant access changed');
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Mark a connection for an immediate re-check and start it. */
|
|
326
|
+
function markStale(connection: WsConnection): void {
|
|
327
|
+
connection.stale = true;
|
|
328
|
+
void ensureWsConnectionCurrent(connection);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Re-check, at once, every open connection of a user — after a membership, role or credential change.
|
|
333
|
+
* Answers the number of connections affected. Process-local: the tenant guard's Redis broadcast is
|
|
334
|
+
* what carries a membership invalidation to the other replicas.
|
|
335
|
+
*/
|
|
336
|
+
export function revalidateWsConnectionsOf(identity: IWsConnectionIdentity): number {
|
|
337
|
+
const userId = identity?.userId !== undefined && identity?.userId !== null ? String(identity.userId) : undefined;
|
|
338
|
+
const iamId = identity?.iamId || undefined;
|
|
339
|
+
const email = typeof identity?.email === 'string' ? identity.email.toLowerCase() : undefined;
|
|
340
|
+
if (!userId && !iamId && !email) {
|
|
341
|
+
return 0;
|
|
342
|
+
}
|
|
343
|
+
let count = 0;
|
|
344
|
+
for (const connection of liveConnections) {
|
|
345
|
+
if (
|
|
346
|
+
(userId && connection.userId === userId) ||
|
|
347
|
+
(iamId && connection.iamId === iamId) ||
|
|
348
|
+
(email && connection.email === email)
|
|
349
|
+
) {
|
|
350
|
+
markStale(connection);
|
|
351
|
+
count++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return count;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Re-check, at once, every open connection scoped to a tenant — after the tenant was switched on or off. */
|
|
358
|
+
export function revalidateWsConnectionsForTenant(tenantId: string): number {
|
|
359
|
+
if (!tenantId) {
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
const id = String(tenantId);
|
|
363
|
+
let count = 0;
|
|
364
|
+
for (const connection of liveConnections) {
|
|
365
|
+
const tenant = connection.tenant;
|
|
366
|
+
if (tenant && (tenant.tenantId === id || (Array.isArray(tenant.tenantIds) && tenant.tenantIds.includes(id)))) {
|
|
367
|
+
markStale(connection);
|
|
368
|
+
count++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return count;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Re-check every open connection — after a change whose reach is unknown (`invalidateAll()`). */
|
|
375
|
+
export function revalidateAllWsConnections(): number {
|
|
376
|
+
let count = 0;
|
|
377
|
+
for (const connection of liveConnections) {
|
|
378
|
+
markStale(connection);
|
|
379
|
+
count++;
|
|
380
|
+
}
|
|
381
|
+
return count;
|
|
382
|
+
}
|