@nxgt/janus-graphql 0.1.1 → 0.3.0
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/README.md +110 -21
- package/dist/connection/connection.d.ts +36 -0
- package/dist/connection/connection.d.ts.map +1 -0
- package/dist/connection/credential.d.ts +34 -0
- package/dist/connection/credential.d.ts.map +1 -0
- package/dist/connection/types.d.ts +57 -0
- package/dist/connection/types.d.ts.map +1 -0
- package/dist/context.d.ts +11 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/directives/apply.d.ts +10 -9
- package/dist/directives/apply.d.ts.map +1 -1
- package/dist/directives/guard.d.ts +2 -1
- package/dist/directives/guard.d.ts.map +1 -1
- package/dist/directives/read.d.ts +9 -1
- package/dist/directives/read.d.ts.map +1 -1
- package/dist/directives/validate.d.ts +7 -2
- package/dist/directives/validate.d.ts.map +1 -1
- package/dist/fresh.d.ts +37 -0
- package/dist/fresh.d.ts.map +1 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +170 -23
- package/dist/index.js.map +14 -10
- package/dist/options.d.ts +17 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/plugin.d.ts +8 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/sdl.d.ts.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/README.md +5 -3
- package/docs/guide/context.md +36 -3
- package/docs/guide/directives.md +64 -5
- package/docs/guide/errors.md +2 -1
- package/docs/guide/federation.md +2 -1
- package/docs/guide/step-up.md +186 -0
- package/docs/guide/subscriptions.md +251 -0
- package/docs/roadmap.md +29 -9
- package/docs/troubleshooting.md +218 -11
- package/graphql/janus.graphqls +8 -0
- package/package.json +11 -2
package/docs/guide/directives.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Directives
|
|
2
2
|
|
|
3
3
|
This page is for guarding a schema: `@authenticated`, which asks who the user
|
|
4
|
-
is,
|
|
4
|
+
is, `@fresh`, which asks how recently their session proved it, and
|
|
5
|
+
`@permission`, which asks what the user may do to one object. All three
|
|
5
6
|
are declared in `janusTypeDefs`, applied by `useJanus()` when the server
|
|
6
7
|
builds its schema, and checked before the resolver runs — a refused request
|
|
7
8
|
never reaches it.
|
|
@@ -26,7 +27,7 @@ export const yoga = createYoga({
|
|
|
26
27
|
|
|
27
28
|
## Where a directive applies
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
Every directive goes on a field, an object type or an interface:
|
|
30
31
|
|
|
31
32
|
| Written on | Guards |
|
|
32
33
|
| --- | --- |
|
|
@@ -78,6 +79,57 @@ On `Ward.chart` above, the type's `["staff"]` and the field's
|
|
|
78
79
|
Under `useJanus({ type: 'staff' })`, the only user type a directive may name
|
|
79
80
|
is `'staff'`.
|
|
80
81
|
|
|
82
|
+
## `@fresh(maxAge: Int!)`
|
|
83
|
+
|
|
84
|
+
```graphql
|
|
85
|
+
type Mutation {
|
|
86
|
+
changeEmail(email: String!): User @fresh(maxAge: 600) # signed in, or confirmed, in the last ten minutes
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type PaymentMethods @fresh(maxAge: 600) { # every field of PaymentMethods
|
|
90
|
+
cards: [Card!]!
|
|
91
|
+
add(token: String!): Card @fresh(maxAge: 60) # the smallest maxAge holds: one minute
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Only a session that proved who it is **less than `maxAge` seconds ago**
|
|
96
|
+
reaches the field: signed in, or confirmed since by `auth.stepUp.confirm`,
|
|
97
|
+
which moves the session's `authenticatedAt` to now. Renewing a sliding
|
|
98
|
+
session does not. It is `@nxgt/janus`'s `assertFresh`, and
|
|
99
|
+
`@nxgt/janus-hono`'s `fresh()`, for a schema.
|
|
100
|
+
|
|
101
|
+
**`maxAge` is in seconds**, an `Int`: `600` is ten minutes. Seconds because
|
|
102
|
+
a GraphQL `Int` is 32 bits — about 24 days in milliseconds — and because a
|
|
103
|
+
freshness limit is written in seconds elsewhere, such as OpenID Connect's
|
|
104
|
+
`max_age`. `@nxgt/janus`'s durations read a bare number as milliseconds;
|
|
105
|
+
here there is no unit to write, so it is seconds, always.
|
|
106
|
+
|
|
107
|
+
| The request | Answered |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| anonymous | `UNAUTHENTICATED`, 401 |
|
|
110
|
+
| a user of a type `@authenticated` does not name | `FORBIDDEN`, 403, before freshness is read |
|
|
111
|
+
| a session that proved who it is `maxAge` seconds ago or more | `STEP_UP_REQUIRED`, 403 — the client asks for a step-up |
|
|
112
|
+
| a store that cannot answer | `SERVICE_UNAVAILABLE`, 503, never a denial |
|
|
113
|
+
| a fresh session | every `@permission`, then the resolver |
|
|
114
|
+
|
|
115
|
+
`@fresh` implies a signed-in user, as `@permission` does. **It is checked
|
|
116
|
+
after `@authenticated` and before any `@permission`**, so a stale session
|
|
117
|
+
asks no permission check and loads no object. Where several apply — the
|
|
118
|
+
field's, its type's, its interfaces' — **the smallest `maxAge` holds**. The
|
|
119
|
+
refusal is at `maxAge` exactly: a session `maxAge` seconds old is refused,
|
|
120
|
+
as `assertFresh` refuses it.
|
|
121
|
+
|
|
122
|
+
**A `@fresh` on a subscription field is checked when it subscribes**: a
|
|
123
|
+
fresh one keeps receiving events past `maxAge`. A `@fresh` on the payload's
|
|
124
|
+
type or its fields is checked on every event, as any field. See
|
|
125
|
+
[the step-up over GraphQL](step-up.md#subscriptions).
|
|
126
|
+
|
|
127
|
+
The time is `useJanus({ clock })`'s — give it the clock given to `janus()`
|
|
128
|
+
when that is not the system's, such as `fixedClock` in a spec. The flow a
|
|
129
|
+
client follows on `STEP_UP_REQUIRED`, with `stepUp.request` and `confirm` as
|
|
130
|
+
mutations, is in [the step-up over GraphQL](step-up.md); `requireFresh(ctx,
|
|
131
|
+
maxAge)` is the same check in a resolver.
|
|
132
|
+
|
|
81
133
|
## `@permission(name, type, id, onDeny)`
|
|
82
134
|
|
|
83
135
|
```graphql
|
|
@@ -102,7 +154,8 @@ reads reaches the field. It is `@nxgt/janus-hono`'s
|
|
|
102
154
|
| `onDeny` | What a denial answers: `NOT_FOUND`, 404, by default, or `FORBIDDEN`, 403 |
|
|
103
155
|
|
|
104
156
|
`@permission` implies `@authenticated`: an anonymous request is answered
|
|
105
|
-
`UNAUTHENTICATED`, 401, before any object is loaded or any check asked.
|
|
157
|
+
`UNAUTHENTICATED`, 401, before any object is loaded or any check asked. A
|
|
158
|
+
`@fresh` that applies is checked before it, too.
|
|
106
159
|
|
|
107
160
|
### Reading the id
|
|
108
161
|
|
|
@@ -213,7 +266,9 @@ permission whose rules name both. They are asked **one at a time, in order**,
|
|
|
213
266
|
and the first that denies answers: the ones after it are never asked.
|
|
214
267
|
|
|
215
268
|
The order across locations is outermost first: the interfaces' directives,
|
|
216
|
-
the type's, the interfaces' fields', then the field's own.
|
|
269
|
+
the type's, the interfaces' fields', then the field's own. Across
|
|
270
|
+
directives, every `@authenticated` comes first, then `@fresh`, then the
|
|
271
|
+
`@permission`s. So a type's
|
|
217
272
|
`NOT_FOUND` answers before a field's `FORBIDDEN` could tell that the object
|
|
218
273
|
exists.
|
|
219
274
|
|
|
@@ -235,6 +290,7 @@ type Record @permission(name: "view", type: "record") {
|
|
|
235
290
|
| --- | --- |
|
|
236
291
|
| anonymous | `UNAUTHENTICATED`, 401 |
|
|
237
292
|
| a user of a type `@authenticated` does not name | `FORBIDDEN`, 403, before any check |
|
|
293
|
+
| a session older than a `@fresh` that applies allows | `STEP_UP_REQUIRED`, 403, before any check |
|
|
238
294
|
| no id at the path, an empty list in the parent, or an id no object can hold | `NOT_FOUND`, 404 |
|
|
239
295
|
| a loader answering `null` | `NOT_FOUND`, 404 |
|
|
240
296
|
| a denial | `onDeny`: `NOT_FOUND`, 404, or `FORBIDDEN`, 403 |
|
|
@@ -278,6 +334,7 @@ field — `(read by Ward.name)`:
|
|
|
278
334
|
TypeError: applyJanusDirectives(): @authenticated on Query.me names the user type 'doctor', which is not one of 'patient', 'staff'
|
|
279
335
|
TypeError: applyJanusDirectives(): @authenticated on Query.me names no user type — leave type: out to admit any signed-in user
|
|
280
336
|
TypeError: applyJanusDirectives(): the @authenticated on Ward.chart and on its type or interfaces admit no user type in common
|
|
337
|
+
TypeError: applyJanusDirectives(): @fresh on Mutation.changeEmail asks maxAge: 0 — write a number of seconds above zero, such as maxAge: 600 for ten minutes
|
|
281
338
|
TypeError: applyJanusDirectives(): @permission on Query.ward needs the permissions() instance — pass useJanus({ auth, access }), with access what permissions() answered
|
|
282
339
|
TypeError: applyJanusDirectives(): @permission on Query.invoice names the object type 'invoice', which is not one of 'record', 'ward'
|
|
283
340
|
TypeError: applyJanusDirectives(): @permission on Query.ward asks 'delete', which ward does not declare — it declares 'nurses', 'visitors', 'enter', 'manage'
|
|
@@ -292,7 +349,9 @@ Each is in [troubleshooting](../troubleshooting.md), with its fix. The
|
|
|
292
349
|
compiler refuses the wiring's own mistakes before that: a loader for a type
|
|
293
350
|
the model does not declare, a loader answering an object without a field a
|
|
294
351
|
`fromField` reads, a condition for a type no `when()` is reached on, a `ctx`
|
|
295
|
-
of the wrong shape,
|
|
352
|
+
of the wrong shape, `loaders` or `conditions` without `access`, and a
|
|
353
|
+
`clock` that is not a `Clock`. A `maxAge` that is not an `Int`, or is
|
|
354
|
+
missing, is refused by graphql-js itself, at start-up too.
|
|
296
355
|
|
|
297
356
|
## The transform alone
|
|
298
357
|
|
package/docs/guide/errors.md
CHANGED
|
@@ -30,8 +30,9 @@ Yoga reads `extensions.http.status` to answer the HTTP response, and strips
|
|
|
30
30
|
|
|
31
31
|
| Code | Status | Message | When |
|
|
32
32
|
| --- | --- | --- | --- |
|
|
33
|
-
| `UNAUTHENTICATED` | 401 | `Not signed in` | An anonymous request reached a field
|
|
33
|
+
| `UNAUTHENTICATED` | 401 | `Not signed in` | An anonymous request reached a guarded field, `requireUser()` or `requireFresh()` |
|
|
34
34
|
| `FORBIDDEN` | 403 | `Forbidden` | A user of a type the directive or `requireUser({ type })` does not name, or a `@permission(onDeny: FORBIDDEN)` denied |
|
|
35
|
+
| `STEP_UP_REQUIRED` | 403 | `Forbidden` | A session older than `@fresh(maxAge)` or `requireFresh(ctx, maxAge)` allows: the client asks for a step-up — see [the step-up over GraphQL](step-up.md) |
|
|
35
36
|
| `NOT_FOUND` | 404 | `Not found` | `@permission`'s default denial, a loader answering `null`, an id `@permission` could not read — and `denial('NOT_FOUND')` |
|
|
36
37
|
| `SERVICE_UNAVAILABLE` | 503 | `The service is unavailable, retry later` | A store could not answer: `STORE_FAILED` |
|
|
37
38
|
| any other `JanusErrorCode` | its `statusOf(code)` | fixed per status: `Invalid request` 400, `Invalid credentials` 401 (`CREDENTIALS_INVALID`, `CODE_INVALID`), `Forbidden` 403, `Not found` 404, `Conflict` 409, `Internal server error` 500 and 501 | A `JanusError` a resolver let through — `CREDENTIALS_INVALID` 401, `LOGIN_TAKEN` 409, `TOKEN_EXPIRED` 400, … |
|
package/docs/guide/federation.md
CHANGED
|
@@ -53,8 +53,9 @@ specification URL of your own and compose them:
|
|
|
53
53
|
```graphql
|
|
54
54
|
extend schema
|
|
55
55
|
@link(url: "https://specs.apollo.dev/federation/v2.1", import: ["@key", "@composeDirective"])
|
|
56
|
-
@link(url: "https://example.com/janus/v1.0", import: ["@authenticated", "@permission"])
|
|
56
|
+
@link(url: "https://example.com/janus/v1.0", import: ["@authenticated", "@fresh", "@permission"])
|
|
57
57
|
@composeDirective(name: "@authenticated")
|
|
58
|
+
@composeDirective(name: "@fresh")
|
|
58
59
|
@composeDirective(name: "@permission")
|
|
59
60
|
```
|
|
60
61
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# A step-up over GraphQL
|
|
2
|
+
|
|
3
|
+
This page is for a mutation a stolen session should not run alone: changing
|
|
4
|
+
the e-mail, a payment method, deleting the account. `@fresh(maxAge)` lets it
|
|
5
|
+
run only for a session that proved who it is less than `maxAge` seconds ago;
|
|
6
|
+
older, it answers `STEP_UP_REQUIRED`, and the client proves it again with a
|
|
7
|
+
step-up. `@nxgt/janus` issues and checks the code, and stamps the session.
|
|
8
|
+
This page shows the whole flow as GraphQL. For the directive alone, see
|
|
9
|
+
[directives](directives.md#freshmaxage-int); for the step-up itself, every
|
|
10
|
+
error and the codes an app confirms, see
|
|
11
|
+
[`@nxgt/janus`'s step-up guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/step-up.md).
|
|
12
|
+
|
|
13
|
+
## The schema
|
|
14
|
+
|
|
15
|
+
```graphql
|
|
16
|
+
type Mutation {
|
|
17
|
+
changeEmail(email: String!): User @fresh(maxAge: 600) # ten minutes
|
|
18
|
+
deleteAccount: Boolean! @fresh(maxAge: 300) # five
|
|
19
|
+
|
|
20
|
+
requestStepUp: StepUpChallenge! @authenticated
|
|
21
|
+
confirmStepUp(challenge: String!, code: String!): Boolean! @authenticated
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StepUpChallenge {
|
|
25
|
+
challenge: String!
|
|
26
|
+
via: String! # "email", or "secondFactor": the code is in the user's app
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`@fresh` implies a signed-in user: an anonymous request is `UNAUTHENTICATED`,
|
|
31
|
+
401, before freshness is read. The two step-up mutations need a signed-in
|
|
32
|
+
user and no fresh one: they are how a session becomes fresh.
|
|
33
|
+
|
|
34
|
+
## The resolvers
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { requireUser, type JanusContext } from '@nxgt/janus-graphql';
|
|
38
|
+
import type { YogaInitialContext } from 'graphql-yoga';
|
|
39
|
+
|
|
40
|
+
type Context = YogaInitialContext & JanusContext<typeof auth>;
|
|
41
|
+
|
|
42
|
+
const resolvers = {
|
|
43
|
+
Mutation: {
|
|
44
|
+
changeEmail: (_: unknown, { email }: { email: string }, ctx: Context) =>
|
|
45
|
+
users.changeEmail(ctx, email), // runs only on a fresh session
|
|
46
|
+
|
|
47
|
+
requestStepUp: async (_: unknown, __: unknown, ctx: Context) => {
|
|
48
|
+
const user = await requireUser(ctx); // the session's user — never an id the client sent
|
|
49
|
+
const issued = await auth.stepUp.request(user);
|
|
50
|
+
if (issued.via === 'email') {
|
|
51
|
+
await sendMail(issued.email, 'Confirm it is you', `${issued.code} confirms the action.`);
|
|
52
|
+
}
|
|
53
|
+
return { challenge: issued.challenge, via: issued.via }; // never the code
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
confirmStepUp: async (
|
|
57
|
+
_: unknown,
|
|
58
|
+
{ challenge, code }: { challenge: string; code: string },
|
|
59
|
+
ctx: Context,
|
|
60
|
+
) => {
|
|
61
|
+
await auth.stepUp.confirm(ctx.request, challenge, code); // stamps this request's session
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`stepUp.confirm` takes the **request**, not a user: it stamps the session
|
|
69
|
+
that request presents, and only a standing session of the challenge's user.
|
|
70
|
+
`ctx.request` is the `Request` Yoga puts on the context, and carries the same
|
|
71
|
+
token or cookie as the mutation that was refused. With several user types,
|
|
72
|
+
`stepUp` is on each type that has an e-mail — `auth.patient.stepUp` — and
|
|
73
|
+
`requireUser(ctx, { type: 'patient' })` narrows the user to it.
|
|
74
|
+
|
|
75
|
+
**Wire `janusMaskError()`.** A wrong code is a `JanusError` the resolver lets
|
|
76
|
+
through — `CODE_INVALID`, 401, with `attemptsLeft` — and without the mask
|
|
77
|
+
Yoga answers it as a 500.
|
|
78
|
+
|
|
79
|
+
## What the client does
|
|
80
|
+
|
|
81
|
+
1. It sends `changeEmail`. The session is older than ten minutes:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{ "data": { "changeEmail": null },
|
|
85
|
+
"errors": [{ "message": "Forbidden", "path": ["changeEmail"], "extensions": { "code": "STEP_UP_REQUIRED" } }] }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The response is a 403. The client reads `extensions.code`, never the
|
|
89
|
+
message, which is the fixed one of the status.
|
|
90
|
+
|
|
91
|
+
2. It sends `requestStepUp`, and keeps `challenge`. The user reads the code
|
|
92
|
+
in the e-mail — or in their app, when `via` is `"secondFactor"`.
|
|
93
|
+
|
|
94
|
+
3. It sends `confirmStepUp(challenge, code)`. From now on, the session is
|
|
95
|
+
fresh for `maxAge` seconds.
|
|
96
|
+
|
|
97
|
+
4. It sends `changeEmail` again, which runs.
|
|
98
|
+
|
|
99
|
+
| `confirmStepUp` answers | Status | The client |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `true` | 200 | sends the refused mutation again |
|
|
102
|
+
| `CODE_INVALID`, with `attemptsLeft` | 401 | asks for the code again; at `0`, starts over at `requestStepUp` |
|
|
103
|
+
| `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_UNKNOWN`, `TOKEN_STALE` | 400 | starts over at `requestStepUp` |
|
|
104
|
+
| `SECOND_FACTOR_ACTIVE` | 409 | starts over: the user's app confirms now |
|
|
105
|
+
| `SECOND_FACTOR_NOT_ENROLLED` | 409 | starts over: the factor was disabled, an e-mailed code confirms now |
|
|
106
|
+
| `USER_INACTIVE` | 403 | stops: the account was deactivated |
|
|
107
|
+
| `SERVICE_UNAVAILABLE` | 503 | retries later |
|
|
108
|
+
|
|
109
|
+
`requestStepUp` itself answers `USER_INACTIVE`, 403, for a deactivated
|
|
110
|
+
user, and `NOT_FOUND`, 404, for a user with no e-mail to send a code to
|
|
111
|
+
— an optional e-mail left empty.
|
|
112
|
+
|
|
113
|
+
**Rate-limit `requestStepUp` per user.** A new challenge takes only a new
|
|
114
|
+
request, and each one sends an e-mail: a few an hour is plenty for a person.
|
|
115
|
+
|
|
116
|
+
## In a resolver: `requireFresh(ctx, maxAge)`
|
|
117
|
+
|
|
118
|
+
Where the rule depends on the arguments, ask in the resolver:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { requireFresh } from '@nxgt/janus-graphql';
|
|
122
|
+
|
|
123
|
+
const resolvers = {
|
|
124
|
+
Mutation: {
|
|
125
|
+
transfer: async (_: unknown, { amount }: { amount: number }, ctx: Context) => {
|
|
126
|
+
if (amount > 1_000) await requireFresh(ctx, '5m'); // STEP_UP_REQUIRED past five minutes
|
|
127
|
+
return payments.transfer(ctx, amount);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
It answers the session, and refuses as `@fresh` does. `maxAge` is a
|
|
134
|
+
duration with its unit — `'5m'`, `'300s'` — never a bare number: the
|
|
135
|
+
compiler refuses one, and so does the call at run time, because
|
|
136
|
+
`@nxgt/janus` reads a bare number as milliseconds where `@fresh` reads
|
|
137
|
+
seconds.
|
|
138
|
+
|
|
139
|
+
## Subscriptions
|
|
140
|
+
|
|
141
|
+
**A `@fresh` on the subscription field is checked when it subscribes.** A
|
|
142
|
+
stale session is refused before the stream starts; a fresh one keeps
|
|
143
|
+
receiving events past `maxAge`, as it keeps its session. `@permission` on
|
|
144
|
+
the field is asked again on every event; `@authenticated` reads the
|
|
145
|
+
operation's one authentication, made when it subscribed; its `@fresh` is
|
|
146
|
+
not asked again. To end a stream when the session is no longer fresh, close
|
|
147
|
+
it from the server.
|
|
148
|
+
|
|
149
|
+
Over a WebSocket, the same holds once `janusConnection()` authenticates
|
|
150
|
+
the connection: [subscriptions over graphql-ws](subscriptions.md).
|
|
151
|
+
|
|
152
|
+
**A `@fresh` on the payload's type, or on one of its fields, is checked on
|
|
153
|
+
every event**, as any field is: those fields resolve anew for each event,
|
|
154
|
+
and once the session is past `maxAge` they answer `STEP_UP_REQUIRED` while
|
|
155
|
+
the stream goes on.
|
|
156
|
+
|
|
157
|
+
```graphql
|
|
158
|
+
type Subscription {
|
|
159
|
+
receipts: Receipt @fresh(maxAge: 600) # checked once, when it subscribes
|
|
160
|
+
}
|
|
161
|
+
type Receipt @fresh(maxAge: 600) { # checked on every event that carries one
|
|
162
|
+
amount: Int
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Testing it
|
|
167
|
+
|
|
168
|
+
`useJanus({ clock })` is the clock `@fresh` and `requireFresh()` read. Give
|
|
169
|
+
it the one given to `janus()`, and a spec moves both at once:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { fixedClock, janus } from '@nxgt/janus';
|
|
173
|
+
|
|
174
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
175
|
+
const auth = janus({ /* … */ clock });
|
|
176
|
+
const yoga = createYoga({ schema, plugins: [useJanus({ auth, clock })] });
|
|
177
|
+
|
|
178
|
+
const { token } = await auth.signUp({ email, password }); // signed in now
|
|
179
|
+
clock.advance(10 * 60_000); // ten minutes later
|
|
180
|
+
// changeEmail now answers STEP_UP_REQUIRED
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Without `clock`, both read the system's. A `janus()` on a fixed clock and a
|
|
184
|
+
`useJanus()` without one disagree on the time: a session signed in on
|
|
185
|
+
the fixed clock looks days old, and every `@fresh` field answers
|
|
186
|
+
`STEP_UP_REQUIRED`.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Subscriptions over graphql-ws
|
|
2
|
+
|
|
3
|
+
A subscription over server-sent events is an HTTP request like any other:
|
|
4
|
+
`useJanus()` authenticates it from its headers, and nothing more is needed.
|
|
5
|
+
Over a WebSocket, every operation travels over one socket, opened once:
|
|
6
|
+
`janusConnection()` authenticates that socket, with
|
|
7
|
+
[graphql-ws](https://github.com/enisdenjo/graphql-ws), the library Yoga
|
|
8
|
+
recommends, so the directives hold on it unchanged.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
`graphql-ws` is an **optional** peer: install it only when you serve
|
|
13
|
+
subscriptions over a WebSocket.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add graphql-ws ws
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Its range, `^6.0.0`, and the `graphql` 17 note are in the
|
|
20
|
+
[README's Install](../../README.md#install).
|
|
21
|
+
|
|
22
|
+
## Wiring, with Yoga
|
|
23
|
+
|
|
24
|
+
This is Yoga's recommended graphql-ws setup, with one line added:
|
|
25
|
+
`onConnect`.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createServer } from 'node:http';
|
|
29
|
+
import { janusConnection, janusMaskError, janusTypeDefs, useJanus } from '@nxgt/janus-graphql';
|
|
30
|
+
import { useServer } from 'graphql-ws/use/ws';
|
|
31
|
+
import { createSchema, createYoga } from 'graphql-yoga';
|
|
32
|
+
import { WebSocketServer } from 'ws';
|
|
33
|
+
import { access } from './access'; // what permissions() answered
|
|
34
|
+
import { auth } from './auth'; // what janus() answered
|
|
35
|
+
|
|
36
|
+
const yoga = createYoga({
|
|
37
|
+
schema: createSchema({ typeDefs: [janusTypeDefs, typeDefs], resolvers }),
|
|
38
|
+
plugins: [useJanus({ auth, access })],
|
|
39
|
+
maskedErrors: { maskError: janusMaskError() },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const server = createServer(yoga);
|
|
43
|
+
const wsServer = new WebSocketServer({ server, path: yoga.graphqlEndpoint });
|
|
44
|
+
|
|
45
|
+
useServer(
|
|
46
|
+
{
|
|
47
|
+
onConnect: janusConnection({ auth }).onConnect, // refuses a connection with no valid session
|
|
48
|
+
execute: (args: any) => args.rootValue.execute(args),
|
|
49
|
+
subscribe: (args: any) => args.rootValue.subscribe(args),
|
|
50
|
+
onSubscribe: async (ctx, _id, params) => {
|
|
51
|
+
const { schema, execute, subscribe, contextFactory, parse, validate } = yoga.getEnveloped({
|
|
52
|
+
...ctx, // carries ctx.extra, where useJanus() finds the connection
|
|
53
|
+
req: ctx.extra.request,
|
|
54
|
+
socket: ctx.extra.socket,
|
|
55
|
+
params,
|
|
56
|
+
});
|
|
57
|
+
const args = {
|
|
58
|
+
schema,
|
|
59
|
+
operationName: params.operationName,
|
|
60
|
+
document: parse(params.query),
|
|
61
|
+
variableValues: params.variables,
|
|
62
|
+
contextValue: await contextFactory(),
|
|
63
|
+
rootValue: { execute, subscribe },
|
|
64
|
+
};
|
|
65
|
+
const errors = validate(args.schema, args.document);
|
|
66
|
+
if (errors.length) return errors;
|
|
67
|
+
return args;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
wsServer,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
server.listen(4000);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`useJanus()` needs nothing new. When it builds the context of an operation
|
|
77
|
+
with no `request`, it looks for the connection the operation came from —
|
|
78
|
+
by `ctx.extra`, which the `...ctx` spread carries — and authenticates the
|
|
79
|
+
credential that connection presented. **Keep the spread**: a context built
|
|
80
|
+
without `ctx.extra` has no connection to find, and its first guarded field
|
|
81
|
+
throws [`the GraphQL context has no request to authenticate`](../troubleshooting.md#typeerror--the-graphql-context-has-no-request-to-authenticate).
|
|
82
|
+
|
|
83
|
+
## How the session travels
|
|
84
|
+
|
|
85
|
+
`onConnect` reads one credential per connection, in this order:
|
|
86
|
+
|
|
87
|
+
1. **`connectionParams.authorization`** — for a client that is not a
|
|
88
|
+
browser, or a browser that holds a bearer token:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { createClient } from 'graphql-ws';
|
|
92
|
+
|
|
93
|
+
const client = createClient({
|
|
94
|
+
url: 'wss://api.example.com/graphql',
|
|
95
|
+
connectionParams: async () => ({ authorization: `Bearer ${await tokens.current()}` }),
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The key is `authorization`, lower-case, and its value a string — the
|
|
100
|
+
same `Bearer <token>` an HTTP request sends. A value that is not a
|
|
101
|
+
string refuses the connection; a string of another scheme (`Basic …`)
|
|
102
|
+
is not a session credential, as in `@nxgt/janus`, and the upgrade
|
|
103
|
+
request is read instead.
|
|
104
|
+
|
|
105
|
+
2. **The upgrade request**, read as `auth.authenticate` reads any request:
|
|
106
|
+
`Authorization: Bearer`, then `X-Session-Token`, then the session
|
|
107
|
+
cookie. A browser sends its cookies with the upgrade, so a signed-in
|
|
108
|
+
browser needs no `connectionParams` at all — provided the WebSocket goes
|
|
109
|
+
to the origin that set the cookie, or to one its `SameSite` admits.
|
|
110
|
+
|
|
111
|
+
**The first credential present wins, not the first valid one**, as in
|
|
112
|
+
`@nxgt/janus`: a lapsed token in `connectionParams` beside a live cookie is
|
|
113
|
+
refused, rather than rescued in silence. Fix the client's token.
|
|
114
|
+
|
|
115
|
+
`type` restricts who may connect:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
janusConnection({ auth, type: 'staff' }); // a patient's session is refused
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## What a client sees
|
|
122
|
+
|
|
123
|
+
| What happened | The client sees |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| No credential, an unknown or lapsed one, a user gone, inactive or of another `type`, an `authorization` that is not a string | The socket closed **`4403: Forbidden`**, before any operation — graphql-ws's own close for an `onConnect` that answers `false` |
|
|
126
|
+
| The sessions store could not answer at connect | The socket closed **`4500`**, by `graphql-ws/use/ws`, which also logs the error — **never `4403`** |
|
|
127
|
+
| Accepted | `connection_ack`, then operations as over HTTP |
|
|
128
|
+
|
|
129
|
+
graphql-ws's client treats `4403` as retryable — a `connectionParams`
|
|
130
|
+
function asked again can present a fresh token — and `4500` as fatal. Set
|
|
131
|
+
`retryAttempts` and `shouldRetry` on the client to change that. (`4401` is
|
|
132
|
+
the protocol's answer to an operation sent before the acknowledgement;
|
|
133
|
+
`onConnect` cannot choose it.)
|
|
134
|
+
|
|
135
|
+
## Each operation
|
|
136
|
+
|
|
137
|
+
Each operation on an accepted connection gets **a `ctx.janus` of its own**,
|
|
138
|
+
built by `useJanus()` as for an HTTP request, and authenticated again from
|
|
139
|
+
the connection's credential the first time a field asks — once per
|
|
140
|
+
operation, never per event:
|
|
141
|
+
|
|
142
|
+
- `@authenticated` and `@fresh` on a subscription field are checked **when
|
|
143
|
+
it subscribes**. A denial answers the operation with its `GraphQLError` —
|
|
144
|
+
`UNAUTHENTICATED`, `FORBIDDEN`, `STEP_UP_REQUIRED` — and the source stream
|
|
145
|
+
never starts; the connection stays open.
|
|
146
|
+
- `@permission` on a subscription field is asked **again on every event**,
|
|
147
|
+
bypassing the request's memo, so a `revoke()` stops the stream at its
|
|
148
|
+
next event with `NOT_FOUND` (or `FORBIDDEN`).
|
|
149
|
+
- A directive on the payload's type or its fields is checked on every
|
|
150
|
+
event, as any field.
|
|
151
|
+
- An outage at subscribe, or on an event, answers `SERVICE_UNAVAILABLE` —
|
|
152
|
+
never a denial.
|
|
153
|
+
|
|
154
|
+
## A session revoked while connected
|
|
155
|
+
|
|
156
|
+
`auth.signOut` or `signOutEverywhere` while a socket is open:
|
|
157
|
+
|
|
158
|
+
- **the stream already running keeps its events coming.** Its user was
|
|
159
|
+
authenticated once, when it subscribed, and each event's
|
|
160
|
+
`@authenticated` reads that answer — authenticating on every event would
|
|
161
|
+
cost a sessions-store read per event per subscriber;
|
|
162
|
+
- **the next operation on the same connection is refused**
|
|
163
|
+
`UNAUTHENTICATED`, since it authenticates again;
|
|
164
|
+
- **a reconnection is refused `4403`**.
|
|
165
|
+
|
|
166
|
+
To cut a revoked session's streams at once, close its sockets yourself.
|
|
167
|
+
graphql-ws hands every callback the transport's socket as
|
|
168
|
+
`ctx.extra.socket`: a server that keeps its open sockets by user can close
|
|
169
|
+
them with `4403` when it revokes, and a client that reconnects is then
|
|
170
|
+
refused.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const connection = janusConnection({ auth });
|
|
174
|
+
|
|
175
|
+
useServer({
|
|
176
|
+
onConnect: async (ctx) => {
|
|
177
|
+
const accepted = await connection.onConnect(ctx);
|
|
178
|
+
if (accepted) track(ctx.extra.socket, ctx); // your own bookkeeping, keyed as you revoke
|
|
179
|
+
return accepted;
|
|
180
|
+
},
|
|
181
|
+
onClose: (ctx) => untrack(ctx.extra.socket),
|
|
182
|
+
// … Yoga's recipe …
|
|
183
|
+
}, wsServer);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A permission revoked while connected needs none of this: `@permission` sees
|
|
187
|
+
it at the next event.
|
|
188
|
+
|
|
189
|
+
## Without Yoga
|
|
190
|
+
|
|
191
|
+
A server that runs graphql-ws alone passes `context` as well, and applies
|
|
192
|
+
the directives itself:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { applyJanusDirectives, janusConnection } from '@nxgt/janus-graphql';
|
|
196
|
+
import { useServer } from 'graphql-ws/use/ws';
|
|
197
|
+
import { WebSocketServer } from 'ws';
|
|
198
|
+
import { schema } from './schema'; // your executable schema, with janusTypeDefs
|
|
199
|
+
|
|
200
|
+
const wsServer = new WebSocketServer({ port: 4000, path: '/graphql' });
|
|
201
|
+
const connection = janusConnection({ auth, access, clock }); // clock: the one given to janus()
|
|
202
|
+
|
|
203
|
+
useServer(
|
|
204
|
+
{
|
|
205
|
+
schema: applyJanusDirectives(schema, { auth, access }),
|
|
206
|
+
onConnect: connection.onConnect,
|
|
207
|
+
context: connection.context, // { janus } for each operation
|
|
208
|
+
},
|
|
209
|
+
wsServer,
|
|
210
|
+
);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
With Yoga, leave `context` out: Yoga's recipe builds the context itself,
|
|
214
|
+
and `useJanus()`'s `access` and `clock` are the ones that apply.
|
|
215
|
+
|
|
216
|
+
## On Bun, or another transport
|
|
217
|
+
|
|
218
|
+
`onConnect` reads the upgrade request from `ctx.extra.request`, where
|
|
219
|
+
`graphql-ws/use/ws` and `graphql-ws/use/@fastify/websocket` put it.
|
|
220
|
+
`graphql-ws/use/bun`'s `extra` is `{ socket }` alone: pass the request as
|
|
221
|
+
the socket's `data` when you upgrade, and tell `janusConnection` where it is.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import type { ServerWebSocket } from 'bun';
|
|
225
|
+
import { makeHandler } from 'graphql-ws/use/bun';
|
|
226
|
+
|
|
227
|
+
type Upgraded = ServerWebSocket<{ readonly request: Request }>;
|
|
228
|
+
|
|
229
|
+
const connection = janusConnection({
|
|
230
|
+
auth,
|
|
231
|
+
upgrade: (ctx: { readonly extra: { readonly socket: Upgraded } }) => ctx.extra.socket.data.request,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
Bun.serve({
|
|
235
|
+
fetch: (request, server) =>
|
|
236
|
+
server.upgrade(request, { data: { request } }) ? undefined : yoga.fetch(request, server),
|
|
237
|
+
websocket: makeHandler({ onConnect: connection.onConnect, /* … Yoga's recipe … */ }),
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Without `upgrade` on Bun, only `connectionParams` authenticates: a browser's
|
|
242
|
+
cookie is never read.
|
|
243
|
+
|
|
244
|
+
## What is not covered
|
|
245
|
+
|
|
246
|
+
- **An anonymous connection.** `onConnect` refuses every connection it
|
|
247
|
+
cannot authenticate; serve anonymous clients over server-sent events. An
|
|
248
|
+
operation on a connection no `onConnect` accepted has no credential: its
|
|
249
|
+
first guarded field, or `ctx.janus.user()`, throws a `TypeError`, a 500.
|
|
250
|
+
- **The renewed session cookie.** A WebSocket has no response to set one
|
|
251
|
+
on: renew through your HTTP routes.
|
package/docs/roadmap.md
CHANGED
|
@@ -9,14 +9,6 @@ Nothing between releases.
|
|
|
9
9
|
|
|
10
10
|
## Next
|
|
11
11
|
|
|
12
|
-
- **Subscriptions over graphql-ws** — `janusConnection`, which authenticates
|
|
13
|
-
a WebSocket connection once, from its `connectionParams` or its upgrade
|
|
14
|
-
request, and gives every operation on it the same `ctx.janus`.
|
|
15
|
-
- **`@fresh(maxAge)`** — a field only a session that proved who it is less
|
|
16
|
-
than `maxAge` ago may resolve — signed in, or confirmed since by
|
|
17
|
-
`auth.stepUp.confirm`: a password change, a new e-mail, a payment method,
|
|
18
|
-
asked again of a session that is days old. `@nxgt/janus` 0.12's
|
|
19
|
-
`assertFresh` is the check, and `STEP_UP_REQUIRED` the error it answers.
|
|
20
12
|
- **The renewed session cookie on GraphQL responses** — `authenticate`
|
|
21
13
|
renews a sliding session in passing; the response would then carry its
|
|
22
14
|
`Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
|
|
@@ -24,7 +16,12 @@ Nothing between releases.
|
|
|
24
16
|
|
|
25
17
|
## Later
|
|
26
18
|
|
|
27
|
-
|
|
19
|
+
- **Anonymous graphql-ws connections** — `janusConnection({ anonymous:
|
|
20
|
+
true })`, accepting a connection with no credential so its public fields
|
|
21
|
+
answer and its guarded ones deny, as over HTTP.
|
|
22
|
+
- **Re-authenticating a subscription on each event** — an opt-in for
|
|
23
|
+
streams that must end the moment their session is revoked, at the cost
|
|
24
|
+
of a sessions-store read per event.
|
|
28
25
|
|
|
29
26
|
## Not planned
|
|
30
27
|
|
|
@@ -46,6 +43,29 @@ Nothing yet.
|
|
|
46
43
|
|
|
47
44
|
Newest first; from the first release on, the package's CHANGELOG holds every one.
|
|
48
45
|
|
|
46
|
+
- **Subscriptions over graphql-ws, `janusConnection()`, v0.3.0.** The same
|
|
47
|
+
`@authenticated`, `@fresh` and `@permission` on subscriptions served over
|
|
48
|
+
a WebSocket with graphql-ws: `onConnect` for its `useServer()`
|
|
49
|
+
authenticates the connection from `connectionParams.authorization` — else
|
|
50
|
+
from the upgrade request's headers and the browser's session cookie —
|
|
51
|
+
and refuses it `4403`, or rejects on an outage, closed `4500`. Each
|
|
52
|
+
operation's `ctx.janus` is built by `useJanus()` from that credential, in
|
|
53
|
+
Yoga's recommended setup, and authenticated again when it subscribes, so
|
|
54
|
+
a session revoked since the connection opened is refused at the next
|
|
55
|
+
subscribe. `context` for a server without Yoga, `upgrade` for Bun.
|
|
56
|
+
`graphql-ws` is an optional peer, `^6.0.0`, whose floor runs in CI.
|
|
57
|
+
|
|
58
|
+
- **`@fresh(maxAge)` and `requireFresh()`, v0.2.0.** A field, a type or an
|
|
59
|
+
interface only a session that proved who it is less than `maxAge` seconds
|
|
60
|
+
ago may resolve — signed in, or confirmed since by `auth.stepUp.confirm`:
|
|
61
|
+
a new e-mail or a payment method, asked again of a session that is days
|
|
62
|
+
old. An older session is `STEP_UP_REQUIRED`, 403, which tells the client
|
|
63
|
+
to ask for a step-up; checked after `@authenticated` and before any
|
|
64
|
+
`@permission`, and for a subscription when it subscribes.
|
|
65
|
+
`requireFresh(ctx, '10m')` is the same check in a resolver, and
|
|
66
|
+
`useJanus({ clock })` the clock both read. `@nxgt/janus` 0.12's
|
|
67
|
+
`assertFresh` is the check.
|
|
68
|
+
|
|
49
69
|
- **The first release, v0.1.0.** GraphQL for `@nxgt/janus`, as an envelop
|
|
50
70
|
plugin: `useJanus()` with a lazy `ctx.janus`, authenticated only when a
|
|
51
71
|
field asks; `@authenticated` on fields, types and interfaces, for any user
|