@nxgt/janus-graphql 0.1.1 → 0.2.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 +39 -10
- package/dist/context.d.ts +7 -1
- 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 +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -12
- package/dist/index.js.map +11 -10
- package/dist/plugin.d.ts +4 -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 +4 -3
- package/docs/guide/context.md +32 -2
- 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 +182 -0
- package/docs/roadmap.md +11 -5
- package/docs/troubleshooting.md +128 -4
- package/graphql/janus.graphqls +8 -0
- package/package.json +2 -2
|
@@ -0,0 +1,182 @@
|
|
|
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. `@authenticated`
|
|
144
|
+
and `@permission` on the field are asked again on every event; its
|
|
145
|
+
`@fresh` is not. To end a stream when the session is no longer fresh, close
|
|
146
|
+
it from the server.
|
|
147
|
+
|
|
148
|
+
**A `@fresh` on the payload's type, or on one of its fields, is checked on
|
|
149
|
+
every event**, as any field is: those fields resolve anew for each event,
|
|
150
|
+
and once the session is past `maxAge` they answer `STEP_UP_REQUIRED` while
|
|
151
|
+
the stream goes on.
|
|
152
|
+
|
|
153
|
+
```graphql
|
|
154
|
+
type Subscription {
|
|
155
|
+
receipts: Receipt @fresh(maxAge: 600) # checked once, when it subscribes
|
|
156
|
+
}
|
|
157
|
+
type Receipt @fresh(maxAge: 600) { # checked on every event that carries one
|
|
158
|
+
amount: Int
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Testing it
|
|
163
|
+
|
|
164
|
+
`useJanus({ clock })` is the clock `@fresh` and `requireFresh()` read. Give
|
|
165
|
+
it the one given to `janus()`, and a spec moves both at once:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { fixedClock, janus } from '@nxgt/janus';
|
|
169
|
+
|
|
170
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1));
|
|
171
|
+
const auth = janus({ /* … */ clock });
|
|
172
|
+
const yoga = createYoga({ schema, plugins: [useJanus({ auth, clock })] });
|
|
173
|
+
|
|
174
|
+
const { token } = await auth.signUp({ email, password }); // signed in now
|
|
175
|
+
clock.advance(10 * 60_000); // ten minutes later
|
|
176
|
+
// changeEmail now answers STEP_UP_REQUIRED
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Without `clock`, both read the system's. A `janus()` on a fixed clock and a
|
|
180
|
+
`useJanus()` without one disagree on the time: a session signed in on
|
|
181
|
+
the fixed clock looks days old, and every `@fresh` field answers
|
|
182
|
+
`STEP_UP_REQUIRED`.
|
package/docs/roadmap.md
CHANGED
|
@@ -12,11 +12,6 @@ Nothing between releases.
|
|
|
12
12
|
- **Subscriptions over graphql-ws** — `janusConnection`, which authenticates
|
|
13
13
|
a WebSocket connection once, from its `connectionParams` or its upgrade
|
|
14
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
15
|
- **The renewed session cookie on GraphQL responses** — `authenticate`
|
|
21
16
|
renews a sliding session in passing; the response would then carry its
|
|
22
17
|
`Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
|
|
@@ -46,6 +41,17 @@ Nothing yet.
|
|
|
46
41
|
|
|
47
42
|
Newest first; from the first release on, the package's CHANGELOG holds every one.
|
|
48
43
|
|
|
44
|
+
- **`@fresh(maxAge)` and `requireFresh()`, v0.2.0.** A field, a type or an
|
|
45
|
+
interface only a session that proved who it is less than `maxAge` seconds
|
|
46
|
+
ago may resolve — signed in, or confirmed since by `auth.stepUp.confirm`:
|
|
47
|
+
a new e-mail or a payment method, asked again of a session that is days
|
|
48
|
+
old. An older session is `STEP_UP_REQUIRED`, 403, which tells the client
|
|
49
|
+
to ask for a step-up; checked after `@authenticated` and before any
|
|
50
|
+
`@permission`, and for a subscription when it subscribes.
|
|
51
|
+
`requireFresh(ctx, '10m')` is the same check in a resolver, and
|
|
52
|
+
`useJanus({ clock })` the clock both read. `@nxgt/janus` 0.12's
|
|
53
|
+
`assertFresh` is the check.
|
|
54
|
+
|
|
49
55
|
- **The first release, v0.1.0.** GraphQL for `@nxgt/janus`, as an envelop
|
|
50
56
|
plugin: `useJanus()` with a lazy `ctx.janus`, authenticated only when a
|
|
51
57
|
field asks; `@authenticated` on fields, types and interfaces, for any user
|
package/docs/troubleshooting.md
CHANGED
|
@@ -15,6 +15,7 @@ for what causes each.
|
|
|
15
15
|
- [`TypeError: applyJanusDirectives(): @authenticated on … names the user type '…', which is not one of …`](#typeerror-applyjanusdirectives-authenticated-on--names-the-user-type--which-is-not-one-of-)
|
|
16
16
|
- [`TypeError: applyJanusDirectives(): @authenticated on … names no user type`](#typeerror-applyjanusdirectives-authenticated-on--names-no-user-type)
|
|
17
17
|
- [`TypeError: applyJanusDirectives(): the @authenticated on … admit no user type in common`](#typeerror-applyjanusdirectives-the-authenticated-on--admit-no-user-type-in-common)
|
|
18
|
+
- [`TypeError: applyJanusDirectives(): @fresh on … asks maxAge: …`](#typeerror-applyjanusdirectives-fresh-on--asks-maxage-)
|
|
18
19
|
- [`TypeError: applyJanusDirectives(): @permission on … needs the permissions() instance`](#typeerror-applyjanusdirectives-permission-on--needs-the-permissions-instance)
|
|
19
20
|
- [`TypeError: applyJanusDirectives(): @permission on … names the object type '…', which is not one of …`](#typeerror-applyjanusdirectives-permission-on--names-the-object-type--which-is-not-one-of-)
|
|
20
21
|
- [`TypeError: applyJanusDirectives(): @permission on … asks '…', which … does not declare`](#typeerror-applyjanusdirectives-permission-on--asks--which--does-not-declare)
|
|
@@ -23,20 +24,27 @@ for what causes each.
|
|
|
23
24
|
- [`TypeError: applyJanusDirectives(): @permission on … reads the …'s id from …, and … reads '…' of the object itself (fromField)`](#typeerror-applyjanusdirectives-permission-on--reads-the-s-id-from--and--reads--of-the-object-itself-fromfield)
|
|
24
25
|
- [`TypeError: applyJanusDirectives(): @permission on … asks '…' of …, which reaches a when()`](#typeerror-applyjanusdirectives-permission-on--asks--of--which-reaches-a-when)
|
|
25
26
|
- [`TypeError: applyJanusDirectives(): @permission on … finds loaders.…, which is not a function`](#typeerror-applyjanusdirectives-permission-on--finds-loaders-which-is-not-a-function)
|
|
26
|
-
- [`Unknown directive "@authenticated"`, or `"@permission"`](#unknown-directive-authenticated-or-permission)
|
|
27
|
+
- [`Unknown directive "@authenticated"`, `"@fresh"` or `"@permission"`](#unknown-directive-authenticated-fresh-or-permission)
|
|
28
|
+
- [`Argument "@fresh(maxAge:)" of type "Int!" is required`, or `Argument "maxAge" has invalid value …`](#argument-freshmaxage-of-type-int-is-required-or-argument-maxage-has-invalid-value-)
|
|
27
29
|
- [`TypeError: applyJanusDirectives(): type '…' is not a user type of auth`](#typeerror-applyjanusdirectives-type--is-not-a-user-type-of-auth)
|
|
28
30
|
- [`TypeError: useJanus(): auth is not what janus() answered`](#typeerror-usejanus-auth-is-not-what-janus-answered)
|
|
29
31
|
- [`TypeError: useJanus(): access is not what permissions() answered`](#typeerror-usejanus-access-is-not-what-permissions-answered)
|
|
32
|
+
- [`TypeError: useJanus(): clock is not a Clock`](#typeerror-usejanus-clock-is-not-a-clock)
|
|
30
33
|
|
|
31
34
|
**In a response**
|
|
32
35
|
- [`UNAUTHENTICATED` for a signed-in user](#unauthenticated-for-a-signed-in-user)
|
|
33
36
|
- [`FORBIDDEN` where the user should be allowed](#forbidden-where-the-user-should-be-allowed)
|
|
37
|
+
- [`STEP_UP_REQUIRED`, 403](#step_up_required-403)
|
|
38
|
+
- [`STEP_UP_REQUIRED` for a user who just signed in](#step_up_required-for-a-user-who-just-signed-in)
|
|
34
39
|
- [`SERVICE_UNAVAILABLE`, 503, on every guarded field](#service_unavailable-503-on-every-guarded-field)
|
|
35
40
|
- [`Unexpected error.`, 500, where a `JanusError` was thrown](#unexpected-error-500-where-a-januserror-was-thrown)
|
|
36
41
|
- [`TypeError: … ctx.janus is not set`](#typeerror--ctxjanus-is-not-set)
|
|
37
42
|
- [`TypeError: useJanus(): the GraphQL context has no request to authenticate`](#typeerror-usejanus-the-graphql-context-has-no-request-to-authenticate)
|
|
38
43
|
- [`TypeError: can(): ctx.janus.access is not set`](#typeerror-can-ctxjanusaccess-is-not-set)
|
|
39
44
|
- [`TypeError: requireUser(): type is an empty list, which no user could pass`](#typeerror-requireuser-type-is-an-empty-list-which-no-user-could-pass)
|
|
45
|
+
- [`TypeError: requireFresh(): maxAge is a duration with its unit`](#typeerror-requirefresh-maxage-is-a-duration-with-its-unit)
|
|
46
|
+
- [`TypeError: requireFresh(): maxAge: "…" is not a duration`](#typeerror-requirefresh-maxage--is-not-a-duration)
|
|
47
|
+
- [`TypeError: requireFresh(): maxAge: a duration must be above zero`](#typeerror-requirefresh-maxage-a-duration-must-be-above-zero)
|
|
40
48
|
- [`@permission on … resolved no object id from … — answered NOT_FOUND`](#permission-on--resolved-no-object-id-from---answered-not_found)
|
|
41
49
|
- [`NOT_FOUND` where the user should be allowed](#not_found-where-the-user-should-be-allowed)
|
|
42
50
|
- [A 403 that tells a user the object exists](#a-403-that-tells-a-user-the-object-exists)
|
|
@@ -52,6 +60,9 @@ for what causes each.
|
|
|
52
60
|
- [`'…' does not exist in type 'Loaders<…>'`, or `'Conditions<…>'`](#-does-not-exist-in-type-loaders-or-conditions)
|
|
53
61
|
- [`Type '{ …: … }' is not assignable to type 'never'`, on `loaders`](#type-----is-not-assignable-to-type-never-on-loaders)
|
|
54
62
|
- [`Source has 0 element(s) but target requires 1`, on `requireUser()`](#source-has-0-elements-but-target-requires-1-on-requireuser)
|
|
63
|
+
- [`Argument of type '600' is not assignable to parameter of type '`${number}ms` | …'`, on `requireFresh()`](#argument-of-type-600-is-not-assignable-to-parameter-of-type-numberms---on-requirefresh)
|
|
64
|
+
- [`Expected 2 arguments, but got 1`, on `requireFresh()`](#expected-2-arguments-but-got-1-on-requirefresh)
|
|
65
|
+
- [`Type 'number' is not assignable to type 'Clock'`](#type-number-is-not-assignable-to-type-clock)
|
|
55
66
|
|
|
56
67
|
## At start-up
|
|
57
68
|
|
|
@@ -95,6 +106,24 @@ type Ward @authenticated(type: ["staff"]) {
|
|
|
95
106
|
**Fix:** widen one of them, or move the field to a type whose directive
|
|
96
107
|
admits the users it is for.
|
|
97
108
|
|
|
109
|
+
### `TypeError: applyJanusDirectives(): @fresh on … asks maxAge: …`
|
|
110
|
+
|
|
111
|
+
**When:** at start-up, for a schema that uses `@fresh`.
|
|
112
|
+
|
|
113
|
+
**Why:** `maxAge` is not above zero — `@fresh(maxAge: 0)` or a negative
|
|
114
|
+
number — so no session could ever be fresh enough, and every request would
|
|
115
|
+
be refused. The message names the field, and the location the directive was
|
|
116
|
+
written on when it is not the field: `@fresh on Account (read by
|
|
117
|
+
Account.email)`.
|
|
118
|
+
|
|
119
|
+
**Fix:** write a number of **seconds** above zero:
|
|
120
|
+
|
|
121
|
+
```graphql
|
|
122
|
+
type Mutation {
|
|
123
|
+
changeEmail(email: String!): User @fresh(maxAge: 600) # ten minutes
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
98
127
|
### `TypeError: applyJanusDirectives(): @permission on … needs the permissions() instance`
|
|
99
128
|
|
|
100
129
|
**When:** at start-up, for a schema that uses `@permission`.
|
|
@@ -192,7 +221,7 @@ than a function of it, from JavaScript or past a cast.
|
|
|
192
221
|
|
|
193
222
|
**Fix:** `loaders: { record: (id) => records.find(id) }`.
|
|
194
223
|
|
|
195
|
-
### `Unknown directive "@authenticated"`, or `"@permission"`
|
|
224
|
+
### `Unknown directive "@authenticated"`, `"@fresh"` or `"@permission"`
|
|
196
225
|
|
|
197
226
|
**Why:** graphql-js builds the schema before `useJanus()` sees it, and the
|
|
198
227
|
directive is not declared.
|
|
@@ -202,6 +231,18 @@ directive is not declared.
|
|
|
202
231
|
generator reading files takes
|
|
203
232
|
`node_modules/@nxgt/janus-graphql/graphql/janus.graphqls`.
|
|
204
233
|
|
|
234
|
+
### `Argument "@fresh(maxAge:)" of type "Int!" is required`, or `Argument "maxAge" has invalid value …`
|
|
235
|
+
|
|
236
|
+
graphql 16 words the first `Directive "@fresh" argument "maxAge" of type
|
|
237
|
+
"Int!" is required, but it was not provided.`
|
|
238
|
+
|
|
239
|
+
**Why:** graphql-js refuses the directive at start-up: `@fresh` without
|
|
240
|
+
`maxAge`, when the schema is built; or with a value that is not an `Int` —
|
|
241
|
+
a fraction, a string such as `"10m"` — when `useJanus()` reads it.
|
|
242
|
+
|
|
243
|
+
**Fix:** a whole number of seconds: `@fresh(maxAge: 600)`. A duration with a
|
|
244
|
+
unit belongs to `requireFresh(ctx, '10m')`, in a resolver.
|
|
245
|
+
|
|
205
246
|
### `TypeError: applyJanusDirectives(): type '…' is not a user type of auth`
|
|
206
247
|
|
|
207
248
|
**When:** at start-up, with `useJanus({ auth, type })` or
|
|
@@ -228,6 +269,15 @@ its place, or the module that exports `auth` not loaded yet.
|
|
|
228
269
|
**Fix:** `useJanus({ auth, access })`, with `access` what `permissions()`
|
|
229
270
|
answered — or leave `access` out when no field uses `@permission` or `can()`.
|
|
230
271
|
|
|
272
|
+
### `TypeError: useJanus(): clock is not a Clock`
|
|
273
|
+
|
|
274
|
+
**Why:** `clock` has no `now()` — a timestamp such as `Date.now()`, or a
|
|
275
|
+
`Date`, passed from JavaScript or cast past the compiler.
|
|
276
|
+
|
|
277
|
+
**Fix:** pass the clock given to `janus()` — `fixedClock(…)` from
|
|
278
|
+
`@nxgt/janus` in a spec — or leave `clock` out for the system's:
|
|
279
|
+
`useJanus({ auth, clock })`.
|
|
280
|
+
|
|
231
281
|
## In a response
|
|
232
282
|
|
|
233
283
|
### `UNAUTHENTICATED` for a signed-in user
|
|
@@ -251,12 +301,37 @@ says applies to it too.
|
|
|
251
301
|
**Fix:** read the directives on the field, on its type, and on every
|
|
252
302
|
interface the type implements.
|
|
253
303
|
|
|
304
|
+
### `STEP_UP_REQUIRED`, 403
|
|
305
|
+
|
|
306
|
+
**Why:** a field `@fresh(maxAge)` guards, or a `requireFresh(ctx, maxAge)`,
|
|
307
|
+
was reached by a session that proved who it is `maxAge` ago or more — at
|
|
308
|
+
its sign-in, or at its last `auth.stepUp.confirm`. Renewing a sliding
|
|
309
|
+
session does not count. This is the answer working: it tells the client to
|
|
310
|
+
ask for a step-up.
|
|
311
|
+
|
|
312
|
+
**Fix:** in the client, on `extensions.code === 'STEP_UP_REQUIRED'`, run the
|
|
313
|
+
step-up — `requestStepUp`, then `confirmStepUp(challenge, code)` — and send
|
|
314
|
+
the request again. The mutations are in
|
|
315
|
+
[the step-up over GraphQL](guide/step-up.md).
|
|
316
|
+
|
|
317
|
+
### `STEP_UP_REQUIRED` for a user who just signed in
|
|
318
|
+
|
|
319
|
+
**Why:** `@fresh` reads the time from `useJanus({ clock })`, and the session
|
|
320
|
+
was stamped with `janus()`'s. Two different clocks: `janus({ clock:
|
|
321
|
+
fixedClock(…) })` with `useJanus()` given none, in a spec — the session looks
|
|
322
|
+
days old. Or `maxAge` written as minutes: `@fresh(maxAge: 10)` is ten
|
|
323
|
+
seconds, not ten minutes.
|
|
324
|
+
|
|
325
|
+
**Fix:** give both the same clock — `useJanus({ auth, clock })` — and write
|
|
326
|
+
`maxAge` in seconds: `@fresh(maxAge: 600)` for ten minutes.
|
|
327
|
+
|
|
254
328
|
### `SERVICE_UNAVAILABLE`, 503, on every guarded field
|
|
255
329
|
|
|
256
330
|
**Why:** a store could not answer — `STORE_FAILED`. The sessions store for
|
|
257
|
-
`@authenticated`, `requireUser()
|
|
331
|
+
`@authenticated`, `@fresh`, `requireUser()`, `requireFresh()` and
|
|
332
|
+
`ctx.janus.user()`; the relation store
|
|
258
333
|
for `can()` and `@permission` — and a loader of yours that threw
|
|
259
|
-
`StoreFailure`. It is never answered `UNAUTHENTICATED` or `
|
|
334
|
+
`StoreFailure`. It is never answered `UNAUTHENTICATED`, `FORBIDDEN` or `STEP_UP_REQUIRED`.
|
|
260
335
|
|
|
261
336
|
**Fix:** the store: its connection, its credentials, its availability. The
|
|
262
337
|
message carries nothing of the store's; log the error where you call
|
|
@@ -307,6 +382,30 @@ signed-in user:
|
|
|
307
382
|
const user = await requireUser(ctx, allowed.length > 0 ? { type: allowed } : {});
|
|
308
383
|
```
|
|
309
384
|
|
|
385
|
+
### `TypeError: requireFresh(): maxAge is a duration with its unit`
|
|
386
|
+
|
|
387
|
+
**Why:** `requireFresh(ctx, 600)` — a bare number, from JavaScript or built
|
|
388
|
+
at run time. `@nxgt/janus` would read it as 600 milliseconds, where
|
|
389
|
+
`@fresh(maxAge: 600)` reads ten minutes, so it is refused rather than
|
|
390
|
+
guessed. The compiler refuses a literal number.
|
|
391
|
+
|
|
392
|
+
**Fix:** write the unit: `requireFresh(ctx, '10m')`, or `'600s'`.
|
|
393
|
+
|
|
394
|
+
### `TypeError: requireFresh(): maxAge: "…" is not a duration`
|
|
395
|
+
|
|
396
|
+
**Why:** the string is not a number followed by `ms`, `s`, `m`, `h` or `d`
|
|
397
|
+
— `'10 minutes'`, `'10 m'`.
|
|
398
|
+
|
|
399
|
+
**Fix:** `requireFresh(ctx, '10m')`.
|
|
400
|
+
|
|
401
|
+
### `TypeError: requireFresh(): maxAge: a duration must be above zero`
|
|
402
|
+
|
|
403
|
+
**Why:** `requireFresh(ctx, '0m')` — no session is ever fresh within no
|
|
404
|
+
time, so every request would be refused. The compiler lets it through: the
|
|
405
|
+
type reads a number and a unit, not their value.
|
|
406
|
+
|
|
407
|
+
**Fix:** a duration above zero, `requireFresh(ctx, '10m')`.
|
|
408
|
+
|
|
310
409
|
### `@permission on … resolved no object id from … — answered NOT_FOUND`
|
|
311
410
|
|
|
312
411
|
**Where:** a warning, through `process.emitWarning` with the code
|
|
@@ -469,3 +568,28 @@ throws
|
|
|
469
568
|
|
|
470
569
|
**Fix:** name at least one user type, `{ type: ['staff'] }`, or leave `type`
|
|
471
570
|
out to admit any signed-in user.
|
|
571
|
+
|
|
572
|
+
### `Argument of type '600' is not assignable to parameter of type '`${number}ms` | …'`, on `requireFresh()`
|
|
573
|
+
|
|
574
|
+
The full message ends `` '`${number}ms` | `${number}s` | `${number}m` |
|
|
575
|
+
`${number}h` | `${number}d`' ``; the same for `'10 minutes'`.
|
|
576
|
+
|
|
577
|
+
**Why:** `requireFresh(ctx, maxAge)` takes a duration with its unit. A bare
|
|
578
|
+
number would be milliseconds to `@nxgt/janus` and seconds to `@fresh`, so
|
|
579
|
+
neither is guessed.
|
|
580
|
+
|
|
581
|
+
**Fix:** `requireFresh(ctx, '10m')`.
|
|
582
|
+
|
|
583
|
+
### `Expected 2 arguments, but got 1`, on `requireFresh()`
|
|
584
|
+
|
|
585
|
+
**Why:** `requireFresh(ctx)` — how recent the proof must be has no default.
|
|
586
|
+
|
|
587
|
+
**Fix:** `requireFresh(ctx, '10m')`.
|
|
588
|
+
|
|
589
|
+
### `Type 'number' is not assignable to type 'Clock'`
|
|
590
|
+
|
|
591
|
+
**Why:** `useJanus({ auth, clock: Date.now() })` — a timestamp, where a
|
|
592
|
+
clock answers `now()`.
|
|
593
|
+
|
|
594
|
+
**Fix:** pass the clock given to `janus()`, `fixedClock(…)` in a spec, or
|
|
595
|
+
leave `clock` out.
|
package/graphql/janus.graphqls
CHANGED
|
@@ -7,6 +7,14 @@ directive @authenticated(
|
|
|
7
7
|
type: [String!]
|
|
8
8
|
) on OBJECT | INTERFACE | FIELD_DEFINITION
|
|
9
9
|
|
|
10
|
+
"""
|
|
11
|
+
Only a session that proved who it is less than `maxAge` seconds ago reaches
|
|
12
|
+
this field, or every field of this type or interface: signed in, or confirmed
|
|
13
|
+
since by a step-up. Anonymous is answered UNAUTHENTICATED, an older session
|
|
14
|
+
STEP_UP_REQUIRED. Where several apply, the smallest `maxAge` holds.
|
|
15
|
+
"""
|
|
16
|
+
directive @fresh(maxAge: Int!) on OBJECT | INTERFACE | FIELD_DEFINITION
|
|
17
|
+
|
|
10
18
|
"""
|
|
11
19
|
What a denied `@permission` answers.
|
|
12
20
|
"""
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus-graphql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "GraphQL for @nxgt/janus: an envelop plugin with the signed-in user on the context, fields guarded by @authenticated and @permission, and every error with the status it deserves",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "GraphQL for @nxgt/janus: an envelop plugin with the signed-in user on the context, fields guarded by @authenticated, @fresh and @permission, and every error with the status it deserves",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|