@nxgt/shared-graphql 2.0.1 → 3.0.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 +196 -81
- package/dist/directives/authenticated.d.ts +42 -0
- package/dist/directives/authenticated.d.ts.map +1 -0
- package/dist/directives/federation.d.ts +6 -0
- package/dist/directives/federation.d.ts.map +1 -1
- package/dist/directives/index.d.ts +5 -1
- package/dist/directives/index.d.ts.map +1 -1
- package/dist/directives/paths.d.ts +20 -0
- package/dist/directives/paths.d.ts.map +1 -0
- package/dist/directives/permission.d.ts +39 -0
- package/dist/directives/permission.d.ts.map +1 -0
- package/dist/directives/removed-check.d.ts +15 -0
- package/dist/directives/removed-check.d.ts.map +1 -0
- package/dist/directives/scope.d.ts +19 -0
- package/dist/directives/scope.d.ts.map +1 -0
- package/dist/directives/sdl.d.ts +26 -0
- package/dist/directives/sdl.d.ts.map +1 -0
- package/dist/directives/validate.d.ts +23 -0
- package/dist/directives/validate.d.ts.map +1 -0
- package/dist/index.js +1048 -564
- package/dist/index.js.map +37 -20
- package/dist/integrations/hono.d.ts +2 -2
- package/dist/integrations/hono.d.ts.map +1 -1
- package/dist/plugins/apply-authenticated.d.ts +21 -0
- package/dist/plugins/apply-authenticated.d.ts.map +1 -0
- package/dist/plugins/apply-keto-checks.d.ts +20 -0
- package/dist/plugins/apply-keto-checks.d.ts.map +1 -0
- package/dist/plugins/auth.d.ts +17 -1
- package/dist/plugins/auth.d.ts.map +1 -1
- package/dist/plugins/authenticated.d.ts +14 -0
- package/dist/plugins/authenticated.d.ts.map +1 -0
- package/dist/plugins/extract-jwt.d.ts +19 -6
- package/dist/plugins/extract-jwt.d.ts.map +1 -1
- package/dist/plugins/field-guard.d.ts +30 -0
- package/dist/plugins/field-guard.d.ts.map +1 -0
- package/dist/plugins/gateway-trust.d.ts +47 -0
- package/dist/plugins/gateway-trust.d.ts.map +1 -0
- package/dist/plugins/index.d.ts +6 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/keto-checker.d.ts +24 -0
- package/dist/plugins/keto-checker.d.ts.map +1 -0
- package/dist/plugins/keto-checks.d.ts +11 -34
- package/dist/plugins/keto-checks.d.ts.map +1 -1
- package/dist/plugins/keto-helpers.d.ts +38 -0
- package/dist/plugins/keto-helpers.d.ts.map +1 -0
- package/dist/plugins/ory-auth.d.ts +5 -5
- package/dist/plugins/ory-auth.d.ts.map +1 -1
- package/dist/scalars/custom/utils.d.ts.map +1 -1
- package/dist/shared.d.ts +1 -1
- package/dist/shared.d.ts.map +1 -1
- package/dist/utils/errors/create-error.d.ts +3 -0
- package/dist/utils/errors/create-error.d.ts.map +1 -0
- package/dist/utils/errors/denial.d.ts +20 -0
- package/dist/utils/errors/denial.d.ts.map +1 -0
- package/dist/utils/errors/format-error.d.ts +12 -0
- package/dist/utils/errors/format-error.d.ts.map +1 -0
- package/dist/utils/errors/index.d.ts +6 -0
- package/dist/utils/errors/index.d.ts.map +1 -0
- package/dist/utils/errors/mask-error.d.ts +25 -0
- package/dist/utils/errors/mask-error.d.ts.map +1 -0
- package/dist/utils/errors/ory-unavailable.d.ts +19 -0
- package/dist/utils/errors/ory-unavailable.d.ts.map +1 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/ws-context.d.ts +1 -1
- package/docs/README.md +13 -0
- package/docs/guide/authentication.md +187 -0
- package/docs/guide/errors.md +136 -0
- package/docs/guide/migrating-to-3.md +276 -0
- package/docs/guide/permissions.md +220 -0
- package/docs/roadmap.md +71 -0
- package/docs/troubleshooting.md +273 -0
- package/graphql/directives/permission.graphqls +73 -0
- package/package.json +11 -13
- package/dist/directives/check.d.ts +0 -40
- package/dist/directives/check.d.ts.map +0 -1
- package/dist/utils/errors.utils.d.ts +0 -23
- package/dist/utils/errors.utils.d.ts.map +0 -1
- package/graphql/directives/check.graphqls +0 -86
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Authentication: who is calling, and `@authenticated`
|
|
2
|
+
|
|
3
|
+
A resolver reads the caller from `context.user`. This page is where that value
|
|
4
|
+
comes from, which sources are trusted, and how `@authenticated` refuses a
|
|
5
|
+
missing caller or one of the wrong kind.
|
|
6
|
+
|
|
7
|
+
## The rule: a verified source, never the body alone
|
|
8
|
+
|
|
9
|
+
The GraphQL request's `extensions` travel in the request body, which any
|
|
10
|
+
client writes. So the caller comes from one of two places:
|
|
11
|
+
|
|
12
|
+
| Source | Plugin | Verified by |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| a Kratos session or a Hydra access token | `useOryAuth(ory)` | Ory, server-side, on every request |
|
|
15
|
+
| what a gateway resolved and put in `extensions` | `useAuth()` / `extractJwtPlugin()` | the gateway's proof — `trustedGateway` — on every request |
|
|
16
|
+
|
|
17
|
+
Anything else leaves the context without a caller.
|
|
18
|
+
|
|
19
|
+
## `useOryAuth(ory)` — an API that authenticates its own callers
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createOry } from '@nxgt/ory-sdk';
|
|
23
|
+
import { useAuthenticated, useOryAuth } from '@nxgt/shared-graphql';
|
|
24
|
+
|
|
25
|
+
const ory = createOry({ /* … */ });
|
|
26
|
+
|
|
27
|
+
createYoga({ plugins: [useOryAuth(ory), useAuthenticated()] });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It reads `Authorization: Bearer`, then `X-Session-Token`, then the Kratos
|
|
31
|
+
cookie, and puts `user`, `claims`, `token` and `ory` on the context. An Ory
|
|
32
|
+
outage throws a 503 `SERVICE_UNAVAILABLE`, never an anonymous caller.
|
|
33
|
+
|
|
34
|
+
## `useAuth()` and `extractJwtPlugin()` — behind a gateway
|
|
35
|
+
|
|
36
|
+
A gateway that authenticated the caller can forward it to subgraphs in the
|
|
37
|
+
request's `extensions`: `user` and `token` for `useAuth()` (Yoga),
|
|
38
|
+
`payload` for `extractJwtPlugin()` (Apollo Server). Both read it **only** when
|
|
39
|
+
`trustedGateway` holds for the request:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { extractJwtPlugin, gatewaySecret, useAuth } from '@nxgt/shared-graphql';
|
|
43
|
+
|
|
44
|
+
const trustedGateway = gatewaySecret({ secret: process.env.GATEWAY_SECRET! });
|
|
45
|
+
|
|
46
|
+
createYoga({ plugins: [useAuth({ trustedGateway })] });
|
|
47
|
+
new ApolloServer({ plugins: [extractJwtPlugin({ trustedGateway })] });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `gatewaySecret({ secret, header? })`
|
|
51
|
+
|
|
52
|
+
Holds for a request whose `header` — `x-gateway-secret` by default — carries
|
|
53
|
+
`secret`, compared in constant time. The gateway adds it to every subgraph
|
|
54
|
+
request; a client that reaches the service directly does not know it, and its
|
|
55
|
+
`extensions` are ignored.
|
|
56
|
+
|
|
57
|
+
| Option | Default | Meaning |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `secret` | — | shared with the gateway only; **at least 16 characters**, or `gatewaySecret` throws when it is built |
|
|
60
|
+
| `header` | `x-gateway-secret` | the header the gateway sends it in |
|
|
61
|
+
|
|
62
|
+
The length check is there for the day `GATEWAY_SECRET` is unset: the server
|
|
63
|
+
stops at boot instead of trusting an empty header.
|
|
64
|
+
|
|
65
|
+
### Another proof
|
|
66
|
+
|
|
67
|
+
`trustedGateway` is any function of the request's headers:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
type GatewayTrust = (headers: { get(name: string): string | null | undefined }) => boolean | Promise<boolean>;
|
|
71
|
+
|
|
72
|
+
// Your proxy terminates mTLS and sets this header only for the gateway's certificate.
|
|
73
|
+
useAuth({ trustedGateway: (headers) => headers.get('x-client-cert-subject') === 'CN=gateway' });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It receives a fetch `Headers` under Yoga and Apollo's `HeaderMap` under
|
|
77
|
+
Apollo Server; both answer `get`.
|
|
78
|
+
|
|
79
|
+
### What is read
|
|
80
|
+
|
|
81
|
+
- `useAuth()`: `extensions.user` when it is an object with a non-empty string
|
|
82
|
+
`sub`, and `extensions.token` when it is a string. Otherwise nothing.
|
|
83
|
+
- `extractJwtPlugin()`: `extensions.payload`, under the same `sub` rule, on
|
|
84
|
+
`context.jwt.payload`.
|
|
85
|
+
- A request that fails the proof leaves `user`, `token` and `jwt` as they
|
|
86
|
+
were — another plugin's caller is not erased.
|
|
87
|
+
- Over WebSocket, `useAuth()` reads no caller: the proof needs a fetch
|
|
88
|
+
`Request`, which a graphql-ws context does not hold. Resolve the caller per
|
|
89
|
+
connection with `resolveWsUser` instead.
|
|
90
|
+
|
|
91
|
+
Without a `trustedGateway`, both throw a `TypeError` when called — see
|
|
92
|
+
[troubleshooting](../troubleshooting.md).
|
|
93
|
+
|
|
94
|
+
## `@authenticated(type: [String!])`
|
|
95
|
+
|
|
96
|
+
```graphql
|
|
97
|
+
directive @authenticated(type: [String!]) on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Declared in `SHARED_TYPE_DEFS` and in `AUTHENTICATED_DIRECTIVE_SDL`; enforced
|
|
101
|
+
by `useAuthenticated()`, or `applyAuthenticated(schema)` on a built schema.
|
|
102
|
+
|
|
103
|
+
```graphql
|
|
104
|
+
type Query {
|
|
105
|
+
me: User @authenticated # any caller
|
|
106
|
+
webhooks: [Webhook!]! @authenticated(type: ["token"]) # a machine client, not a session
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Node @authenticated { id: ID! }
|
|
110
|
+
type Staff implements Node @authenticated(type: ["session"]) { id: ID!, name: String }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Caller | Answer |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| none (`context.user` unset) | `UNAUTHENTICATED`, HTTP 401 |
|
|
116
|
+
| of a type no applicable `type:` names | `FORBIDDEN`, HTTP 403 |
|
|
117
|
+
| otherwise | the resolver runs |
|
|
118
|
+
|
|
119
|
+
- **The caller's type** is `context.ory.kind` — `session` for a Kratos
|
|
120
|
+
session, `token` for an access token — or `context.user.tokenType` when no
|
|
121
|
+
Ory principal is on the context.
|
|
122
|
+
- **Every directive that applies is AND-ed**: the field's, its type's, its
|
|
123
|
+
type's interfaces', and those interfaces' same field. `Staff.name` above
|
|
124
|
+
needs a caller (`Node`) who is a session (`Staff`).
|
|
125
|
+
- **A scalar's or an enum's directive guards every field returning it** —
|
|
126
|
+
`scalar Secret @authenticated` refuses `Query.secret: Secret` to an
|
|
127
|
+
anonymous caller, as federation's router reads it.
|
|
128
|
+
- **A subscription is refused before its stream opens**: the check runs
|
|
129
|
+
before the field's `subscribe`, and again before each event is resolved.
|
|
130
|
+
- **A type's directive guards its fields, not the field returning it.**
|
|
131
|
+
`Query.staff` runs for an anonymous caller; the error lands on
|
|
132
|
+
`staff.name`. Put `@authenticated` on the field too when the lookup must not
|
|
133
|
+
run.
|
|
134
|
+
|
|
135
|
+
### Other caller types
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { CALLER_TYPES, useAuthenticated } from '@nxgt/shared-graphql';
|
|
139
|
+
|
|
140
|
+
useAuthenticated({ types: [...CALLER_TYPES, 'service'] });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`types` lists every value a `type:` may name; `CALLER_TYPES` —
|
|
144
|
+
`['session', 'token']` — is the default. Behind a gateway, it is the set
|
|
145
|
+
of `tokenType`s your gateway writes.
|
|
146
|
+
|
|
147
|
+
### Refused at build
|
|
148
|
+
|
|
149
|
+
`applyAuthenticated` throws a `TypeError` for a directive no request could
|
|
150
|
+
pass, naming where it sits — `Query.me` on a field, `Staff` on a type,
|
|
151
|
+
`Secret` on a scalar:
|
|
152
|
+
|
|
153
|
+
| Mistake | Message starts |
|
|
154
|
+
| --- | --- |
|
|
155
|
+
| `type: []` | ``@authenticated on Query.me: `type: []` admits no caller`` |
|
|
156
|
+
| a type outside `types` | `@authenticated on Query.me: unknown type "staff" — known: session, token` |
|
|
157
|
+
| restrictions with no type in common | ``@authenticated on Note.body: the field's, its type's and its interfaces' `type`s have none in common`` |
|
|
158
|
+
|
|
159
|
+
### Federation's `@authenticated`
|
|
160
|
+
|
|
161
|
+
Federation defines its own `@authenticated`, with **no argument**, and a
|
|
162
|
+
subgraph imports it:
|
|
163
|
+
|
|
164
|
+
```graphql
|
|
165
|
+
extend schema @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@authenticated"])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`useAuthenticated` accepts that shape: an `@authenticated` whose declaration
|
|
169
|
+
has no `type` means "any caller", and is enforced as such. So in a subgraph,
|
|
170
|
+
keep federation's declaration — `FEDERATION_DIRECTIVES` carries it unchanged
|
|
171
|
+
— and do not load `SHARED_TYPE_DEFS` beside it. `type:` is for a schema that
|
|
172
|
+
declares `@authenticated` through `SHARED_TYPE_DEFS` or
|
|
173
|
+
`AUTHENTICATED_DIRECTIVE_SDL`.
|
|
174
|
+
|
|
175
|
+
### Beside `useKetoChecks`
|
|
176
|
+
|
|
177
|
+
Both plugins replace the schema. Each marks the fields it guarded and leaves a
|
|
178
|
+
marked field alone, so the two settle on one schema in either order — and a
|
|
179
|
+
schema merged from a guarded half and an unguarded one gets the second half
|
|
180
|
+
guarded when it is transformed again, as is a field whose resolver was
|
|
181
|
+
replaced since. On a field carrying both,
|
|
182
|
+
`@authenticated` is checked before `@permission` whatever the plugin order,
|
|
183
|
+
so a caller of the wrong type is `FORBIDDEN` before Keto is asked:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
plugins: [useOryAuth(ory), useAuthenticated(), useKetoChecks(ory)]
|
|
187
|
+
```
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Errors: `createMaskError` and `createFormatError`
|
|
2
|
+
|
|
3
|
+
The directives, `requireUser` and `can` throw a **denial**: a `GraphQLError`
|
|
4
|
+
that carries its own code and HTTP status, so any server answers it right.
|
|
5
|
+
Services throw `CustomException` (from `@nxgt/shared-exceptions`), and
|
|
6
|
+
`@nxgt/ory-sdk` throws `OryUnavailable` when Ory cannot answer. Neither of
|
|
7
|
+
those is a `GraphQLError`, so a server has to be told how to answer them —
|
|
8
|
+
and how to translate a denial's message with your resources. These two
|
|
9
|
+
functions do it — one for Yoga, one for Apollo Server.
|
|
10
|
+
|
|
11
|
+
## Denials
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { denial } from '@nxgt/shared-graphql';
|
|
15
|
+
import { ErrorCode } from '@nxgt/shared-exceptions';
|
|
16
|
+
|
|
17
|
+
throw denial(ErrorCode.NotFound, 'notes.errors.not-found');
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Code | `extensions.http.status` | Thrown by |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `UNAUTHENTICATED` | 401 | `@permission`, `@authenticated`, `requireUser`, `can`, with no caller |
|
|
23
|
+
| `FORBIDDEN` | 403 | `@permission(onDeny: FORBIDDEN)`; `@authenticated(type:)` for a caller of another type |
|
|
24
|
+
| `NOT_FOUND` | 404 | `@permission`, by default |
|
|
25
|
+
|
|
26
|
+
The second argument is an i18n key — `errors.unauthenticated`,
|
|
27
|
+
`errors.insufficient-permissions` or `errors.not-found` when omitted. The
|
|
28
|
+
message is that key translated with `@nxgt/i18n`'s own resources, in the
|
|
29
|
+
request's language when one is known; a key those resources do not hold stays
|
|
30
|
+
as it is. `createMaskError(translate)` and `createFormatError(translate)`
|
|
31
|
+
translate it again with yours, and keep its code and status.
|
|
32
|
+
|
|
33
|
+
`denialMessageKey(error)` returns that key, for a mask of your own; the key is
|
|
34
|
+
never serialised to the client.
|
|
35
|
+
|
|
36
|
+
**Without `createMaskError`**, Yoga answers a denial with its status — it is
|
|
37
|
+
a `GraphQLError` thrown on purpose, which Yoga's default mask lets through.
|
|
38
|
+
|
|
39
|
+
## Yoga
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createMaskError } from '@nxgt/shared-graphql';
|
|
43
|
+
import { translate } from './i18n';
|
|
44
|
+
|
|
45
|
+
createYoga({ maskedErrors: { maskError: createMaskError(translate) } });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Apollo Server
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createFormatError } from '@nxgt/shared-graphql';
|
|
52
|
+
|
|
53
|
+
new ApolloServer({ formatError: createFormatError(translate, isProduction) });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## What a client receives — Yoga, through `createMaskError`
|
|
57
|
+
|
|
58
|
+
| Thrown | `extensions.code` | `extensions.http.status` | message |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| a `denial()` | its code | 401, 403 or 404 | its key, translated with your `translate` |
|
|
61
|
+
| `CustomException.unauthenticated()` | `UNAUTHENTICATED` | 401 | translated key |
|
|
62
|
+
| `CustomException.forbidden()` | `FORBIDDEN` | 403 | translated key |
|
|
63
|
+
| `CustomException.notFound()` | `NOT_FOUND` | 404 | translated key |
|
|
64
|
+
| any other `CustomException` | its `errorCode` | its status | translated key |
|
|
65
|
+
| a Mongoose error | through `castError` | its status | translated key |
|
|
66
|
+
| `OryUnavailable` | `SERVICE_UNAVAILABLE` | 503 | `ory: keto is unavailable` |
|
|
67
|
+
| a `GraphQLError` thrown on purpose | its own | its own | its own |
|
|
68
|
+
| a plain `Error` a resolver threw | `INTERNAL_SERVER_ERROR` | — | the mask message |
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"errors": [
|
|
73
|
+
{
|
|
74
|
+
"message": "Could not find the requested resource.",
|
|
75
|
+
"path": ["note"],
|
|
76
|
+
"extensions": { "code": "NOT_FOUND", "http": { "status": 404 } }
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The HTTP status applies when it is the only error of the response, as Yoga
|
|
83
|
+
decides it.
|
|
84
|
+
|
|
85
|
+
## What a client receives — Apollo, through `createFormatError`
|
|
86
|
+
|
|
87
|
+
| Thrown | `extensions.code` | message |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| a `denial()` | its code (`http.status` answers the transport) | its key, translated with your `translate` |
|
|
90
|
+
| a `CustomException` or a Mongoose error | its `errorCode` (plus `debugMessage`) | translated key |
|
|
91
|
+
| `OryUnavailable` | `SERVICE_UNAVAILABLE`, `http: { status: 503 }` in `extensions` | `ory: keto is unavailable` |
|
|
92
|
+
| an Apollo validation or parse error | its own | translated `errors.<code>` |
|
|
93
|
+
| anything else | as Apollo formats it | as Apollo formats it — not masked here |
|
|
94
|
+
|
|
95
|
+
`formatError` shapes the error body; it does not set the response's status,
|
|
96
|
+
so `http.status` is information for the client, not the transport's answer.
|
|
97
|
+
The stack trace is removed when the second argument, `production`, is true.
|
|
98
|
+
|
|
99
|
+
## An outage is a 503, never a denial
|
|
100
|
+
|
|
101
|
+
Kratos, Hydra or Keto failing to answer is not "anonymous" and not "denied".
|
|
102
|
+
Answering it as either would lock every caller out in silence — or, for a
|
|
103
|
+
check that defaulted open, let everyone in. So `OryUnavailable` is never caught
|
|
104
|
+
as a refusal anywhere in this package, and both functions answer it
|
|
105
|
+
`SERVICE_UNAVAILABLE` — with HTTP 503 under Yoga — which a client can retry.
|
|
106
|
+
|
|
107
|
+
It is recognised by class, and also by its name and shape: an install that
|
|
108
|
+
ends up with two copies of `@nxgt/ory-sdk` throws an `OryUnavailable` whose
|
|
109
|
+
class is not the one this package imported, and that must still be a 503
|
|
110
|
+
rather than a masked 500. `isOryUnavailable(error)` is the test, and
|
|
111
|
+
`serviceUnavailableError(error)` builds the 503 `GraphQLError` both use —
|
|
112
|
+
exported for a server that writes its own `maskError`:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { isOryUnavailable, serviceUnavailableError } from '@nxgt/shared-graphql';
|
|
116
|
+
import { GraphQLError } from 'graphql';
|
|
117
|
+
import type { MaskError } from 'graphql-yoga';
|
|
118
|
+
|
|
119
|
+
const maskError: MaskError = (error, message) => {
|
|
120
|
+
const original = error instanceof GraphQLError ? error.originalError : error;
|
|
121
|
+
if (isOryUnavailable(original)) return serviceUnavailableError(original);
|
|
122
|
+
// …
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`oryUnavailableError`, which `useOryAuth` throws when it cannot resolve the
|
|
127
|
+
caller, is the same function under its older name.
|
|
128
|
+
|
|
129
|
+
## Internal messages stay internal
|
|
130
|
+
|
|
131
|
+
graphql-js wraps every error a resolver throws in a `GraphQLError`. A wrapper
|
|
132
|
+
around a plain `Error` — a driver's `connect ECONNREFUSED 10.0.0.5:27017` — is
|
|
133
|
+
replaced by the mask message, as Yoga's default mask does; its `path` is kept.
|
|
134
|
+
In development (`isDev`) the original is in `extensions.debugMessage`.
|
|
135
|
+
|
|
136
|
+
A `GraphQLError` you throw yourself, and every validation error, passes as is.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Migrating to 3.0
|
|
2
|
+
|
|
3
|
+
`@nxgt/shared-graphql` 3.0 is a major release. Most of it is the removals and
|
|
4
|
+
renames 2.x announced, plus one security fix that changes how the caller is
|
|
5
|
+
read. Each section below is one break: what changed, why, and the code before
|
|
6
|
+
and after.
|
|
7
|
+
|
|
8
|
+
| # | Break | You are affected if you… |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| 1 | [`useAuth()` and `extractJwtPlugin` need a trusted gateway](#1-useauth-and-extractjwtplugin-need-a-trusted-gateway) | use either |
|
|
11
|
+
| 2 | [`@check` is removed](#2-check-is-removed) | write `@check`, or import `CHECK_DIRECTIVE_SDL` / `readChecks` / `readRequirements` |
|
|
12
|
+
| 3 | [`@authenticated` takes `type:`, and this package enforces it](#3-authenticated-takes-type) | declare `@authenticated` yourself, or load `SHARED_TYPE_DEFS` |
|
|
13
|
+
| 4 | [Five dependencies are gone](#4-five-dependencies-are-gone) | import one of them without declaring it |
|
|
14
|
+
| 5 | [`@nxgt/ory-sdk` is peered `>=0.1.0 <1`](#5-nxgtory-sdk-is-peered-010-1) | are on an `@nxgt/ory-sdk` 1.x (none is published) |
|
|
15
|
+
| 6 | [`graphql` is peered `^16.9.0 \|\| ^17.0.0`](#6-graphql-is-peered-1690--1700) | are on graphql 16.4 to 16.8 |
|
|
16
|
+
| 7 | [`sandboxExpolorer` is `sandboxExplorer`](#7-sandboxexpolorer-is-sandboxexplorer) | import it |
|
|
17
|
+
| 8 | [A denial is a `GraphQLError`](#8-a-denial-is-a-graphqlerror) | catch a denial as a `CustomException`, or read its `errorCode` |
|
|
18
|
+
|
|
19
|
+
## 1. `useAuth()` and `extractJwtPlugin` need a trusted gateway
|
|
20
|
+
|
|
21
|
+
**Why.** Both copied the caller from the GraphQL request's `extensions` —
|
|
22
|
+
`user` and `token` for `useAuth()`, `payload` for `extractJwtPlugin`. That is
|
|
23
|
+
part of the request body, and any client that reaches the service writes it:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{ "query": "{ me { id } }", "extensions": { "user": { "sub": "someone-else" } } }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
In 2.x that request was answered as `someone-else`. In 3.0 the caller is read
|
|
30
|
+
from `extensions` only for a request that proves it came from your gateway.
|
|
31
|
+
|
|
32
|
+
**Before**
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
createYoga({ plugins: [useAuth()] });
|
|
36
|
+
new ApolloServer({ plugins: [extractJwtPlugin] });
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**After**
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { extractJwtPlugin, gatewaySecret, useAuth } from '@nxgt/shared-graphql';
|
|
43
|
+
|
|
44
|
+
const trustedGateway = gatewaySecret({ secret: process.env.GATEWAY_SECRET! });
|
|
45
|
+
|
|
46
|
+
createYoga({ plugins: [useAuth({ trustedGateway })] });
|
|
47
|
+
new ApolloServer({ plugins: [extractJwtPlugin({ trustedGateway })] });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then make the gateway send the secret on every subgraph request, in the
|
|
51
|
+
`x-gateway-secret` header (or the one you name with `header`). A Hive Gateway,
|
|
52
|
+
for example:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
defineConfig({
|
|
56
|
+
propagateHeaders: {
|
|
57
|
+
fromClientToSubgraphs: () => ({ 'x-gateway-secret': process.env.GATEWAY_SECRET }),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- A request without the header, or with a wrong one, keeps the context
|
|
63
|
+
without a caller: `user`, `token` and `jwt` are left as they were.
|
|
64
|
+
- `gatewaySecret` refuses, when it is built, a secret shorter than 16
|
|
65
|
+
characters — an unset variable stops the server instead of trusting an
|
|
66
|
+
empty header. It compares in constant time.
|
|
67
|
+
- `trustedGateway` can be any `(headers) => boolean | Promise<boolean>`, for a
|
|
68
|
+
proof other than a shared secret (an mTLS header your proxy sets, say).
|
|
69
|
+
- Called without a `trustedGateway`, both throw a `TypeError` when the server
|
|
70
|
+
starts:
|
|
71
|
+
``useAuth(): name the gateway allowed to set the caller — …``.
|
|
72
|
+
- `extractJwtPlugin` is now a function, and no longer logs the payload.
|
|
73
|
+
- A `user` (or `payload`) without a non-empty string `sub` is ignored, even
|
|
74
|
+
from a trusted gateway.
|
|
75
|
+
- Over WebSocket, `useAuth()` reads no caller: a graphql-ws context holds no
|
|
76
|
+
fetch `Request` to carry the proof. Resolve the caller per connection with
|
|
77
|
+
`resolveWsUser`.
|
|
78
|
+
|
|
79
|
+
An API that authenticates its own callers — a Kratos session, a Hydra token —
|
|
80
|
+
does not need a gateway at all: use `useOryAuth(ory)`, which verifies the
|
|
81
|
+
credential server-side.
|
|
82
|
+
|
|
83
|
+
## 2. `@check` is removed
|
|
84
|
+
|
|
85
|
+
**Why.** `@permission` replaced it in 2.1, and 3.0 drops the list-of-lists
|
|
86
|
+
form: one Keto question per directive, AND when repeated, and OR in the Keto
|
|
87
|
+
model.
|
|
88
|
+
|
|
89
|
+
**Before**
|
|
90
|
+
|
|
91
|
+
```graphql
|
|
92
|
+
note(id: ID!): Note @check(permissions: [[{ namespace: "Note", permit: "view" }]])
|
|
93
|
+
|
|
94
|
+
updateNote(id: ID!): Note
|
|
95
|
+
@check(permissions: [[{ namespace: "Note", permit: "view" }]])
|
|
96
|
+
@check(permissions: [[{ namespace: "Note", permit: "edit" }]], onDeny: FORBIDDEN)
|
|
97
|
+
|
|
98
|
+
owner: Person @check(permissions: [[{ namespace: "Person", permit: "view", id: "parent.ownerId" }]])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**After**
|
|
102
|
+
|
|
103
|
+
```graphql
|
|
104
|
+
note(id: ID!): Note @permission(name: "view", type: "Note")
|
|
105
|
+
|
|
106
|
+
updateNote(id: ID!): Note
|
|
107
|
+
@permission(name: "view", type: "Note")
|
|
108
|
+
@permission(name: "edit", type: "Note", onDeny: FORBIDDEN)
|
|
109
|
+
|
|
110
|
+
owner: Person @permission(name: "view", type: "Person", id: "parent.ownerId")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`namespace` is `type`, `permit` is `name`; `id`, `onDeny` and `message` keep
|
|
114
|
+
their meaning. A term group with two terms (`[[A, B]]`, an AND) becomes two
|
|
115
|
+
`@permission`s. A requirement with two groups (`[[A], [B]]`, an OR) has no
|
|
116
|
+
`@permission` spelling: add a permit to the Keto model that unions the two
|
|
117
|
+
relations, and ask that one.
|
|
118
|
+
|
|
119
|
+
**In code:**
|
|
120
|
+
|
|
121
|
+
| 2.x | 3.0 |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `CHECK_DIRECTIVE_SDL` | gone — `PERMISSION_DIRECTIVE_SDL` |
|
|
124
|
+
| `KETO_DIRECTIVES_SDL` | still exported, now `@permission` alone |
|
|
125
|
+
| `readChecks`, `readRequirements` | gone — `readPermissions` |
|
|
126
|
+
| `CheckArgs`, `CheckDenial` | `FieldPermission`, `PermissionDenial` |
|
|
127
|
+
| `graphql/directives/check.graphqls` | no longer shipped |
|
|
128
|
+
|
|
129
|
+
**What 2.x only warned about now stops the server.** An `args.<name>` the
|
|
130
|
+
field does not declare, and a guard on an interface field, were logged as
|
|
131
|
+
warnings on a `@check`. As `@permission`s they are `TypeError`s naming the
|
|
132
|
+
field, when the schema is built.
|
|
133
|
+
|
|
134
|
+
**A `@check` left behind does not boot.** Without its declaration, graphql
|
|
135
|
+
refuses it (`Unknown directive "@check"`). With a declaration of 2.x's shape
|
|
136
|
+
copied into your schema, `useKetoChecks` refuses it:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
@check on Query.note: @check was removed in @nxgt/shared-graphql 3.0 — write one @permission(name: "<permit>", type: "<namespace>") per term, and move an OR into the Keto model
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A `@check` directive of another shape (without `permissions`) is yours, and is
|
|
143
|
+
left alone.
|
|
144
|
+
|
|
145
|
+
## 3. `@authenticated` takes `type:`
|
|
146
|
+
|
|
147
|
+
**Why.** A field meant for a machine client could not refuse a person's
|
|
148
|
+
session. `@authenticated(type: [String!])` names the kinds of caller a field
|
|
149
|
+
admits, and `useAuthenticated()` enforces it.
|
|
150
|
+
|
|
151
|
+
**Before** — `@authenticated` was declared, and enforced by an app's own
|
|
152
|
+
`useGenericAuth`:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
createYoga({ plugins: [useOryAuth(ory), useGenericAuth({ mode: 'protect-granular', resolveUserFn })] });
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**After**
|
|
159
|
+
|
|
160
|
+
```graphql
|
|
161
|
+
me: User @authenticated
|
|
162
|
+
webhooks: [Webhook!]! @authenticated(type: ["token"])
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { useAuthenticated, useOryAuth } from '@nxgt/shared-graphql';
|
|
167
|
+
|
|
168
|
+
createYoga({ plugins: [useOryAuth(ory), useAuthenticated()] });
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- The type is Ory's `kind` — `session` or `token` — or `user.tokenType` when
|
|
172
|
+
no Ory principal is on the context. `useAuthenticated({ types: [...] })`
|
|
173
|
+
names other values; a `type` outside them is refused at build.
|
|
174
|
+
- An anonymous caller is `UNAUTHENTICATED` (401), a caller of another type
|
|
175
|
+
`FORBIDDEN` (403). An `@authenticated` scalar or enum guards every field
|
|
176
|
+
returning it, and a subscription is refused before its stream opens.
|
|
177
|
+
- `SHARED_TYPE_DEFS` now declares `@authenticated(type: [String!])`. If you
|
|
178
|
+
declare `@authenticated` yourself, add the argument or drop your
|
|
179
|
+
declaration; `AUTHENTICATED_DIRECTIVE_SDL` is the declaration as a string.
|
|
180
|
+
- **In a federation subgraph**, keep federation's own `@authenticated`, which
|
|
181
|
+
takes no argument: import it in `@link` as before. `useAuthenticated()`
|
|
182
|
+
reads that shape as "any caller", so the directive is enforced there too.
|
|
183
|
+
`FEDERATION_DIRECTIVES` keeps federation's shape for the same reason.
|
|
184
|
+
|
|
185
|
+
`useGenericAuth` keeps working beside it if you still need its other modes.
|
|
186
|
+
Declare `@envelop/generic-auth` yourself — see the next section.
|
|
187
|
+
|
|
188
|
+
## 4. Five dependencies are gone
|
|
189
|
+
|
|
190
|
+
**Why.** Nothing in this package imported them:
|
|
191
|
+
`@graphql-hive/gateway`, `@envelop/generic-auth`,
|
|
192
|
+
`@envelop/extended-validation`, `@hono/zod-validator`, `zod`.
|
|
193
|
+
|
|
194
|
+
An app that imported one of them without declaring it got it through this
|
|
195
|
+
package, and now gets `Cannot find module`. Declare what you use:
|
|
196
|
+
|
|
197
|
+
```diff
|
|
198
|
+
"dependencies": {
|
|
199
|
+
+ "@envelop/generic-auth": "^11.1.1",
|
|
200
|
+
"@nxgt/shared-graphql": "^3.0.0"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## 5. `@nxgt/ory-sdk` is peered `>=0.1.0 <1`
|
|
205
|
+
|
|
206
|
+
**Why.** An open range admitted a breaking SDK major the day it was
|
|
207
|
+
published, untested. Every published version (0.1.x) is still admitted;
|
|
208
|
+
nothing changes for an install today. `@nxgt/security` and
|
|
209
|
+
`@nxgt/shared-hono` take the same ceiling.
|
|
210
|
+
|
|
211
|
+
## 6. `graphql` is peered `^16.9.0 || ^17.0.0`
|
|
212
|
+
|
|
213
|
+
**Why.** The floor was 16.4.2, which nothing tested. The suite runs on a
|
|
214
|
+
graphql 16 above 16.9 and on graphql 17. On graphql 16.4 to 16.8, upgrade:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
bun add graphql@^16.9.0
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## 7. `sandboxExpolorer` is `sandboxExplorer`
|
|
221
|
+
|
|
222
|
+
**Before**
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { sandboxExpolorer } from '@nxgt/shared-graphql';
|
|
226
|
+
app.get('/sandbox', sandboxExpolorer({ port: 4000 }));
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**After**
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { sandboxExplorer } from '@nxgt/shared-graphql';
|
|
233
|
+
app.get('/sandbox', sandboxExplorer({ port: 4000 }));
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
No alias is kept. `createYogaHono` uses the new name for you.
|
|
237
|
+
|
|
238
|
+
## 8. A denial is a `GraphQLError`
|
|
239
|
+
|
|
240
|
+
**Why.** `@permission`, `requireUser` and `can` threw a `CustomException`,
|
|
241
|
+
which Yoga masks as a 500 unless `createMaskError` is registered. They now
|
|
242
|
+
throw `denial(code, message?)`: a `GraphQLError` carrying
|
|
243
|
+
`extensions { code, http { status } }`, so any server answers 401, 403 or 404.
|
|
244
|
+
`@authenticated` throws the same.
|
|
245
|
+
|
|
246
|
+
What a client receives through `createMaskError` or `createFormatError` does
|
|
247
|
+
not change: the same code, status and translated message.
|
|
248
|
+
|
|
249
|
+
**Before** — code that caught a refusal:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
try {
|
|
253
|
+
requireUser(ctx);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof CustomException && error.errorCode === ErrorCode.Unauthenticated) { … }
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**After**
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { GraphQLError } from 'graphql';
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
requireUser(ctx);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error instanceof GraphQLError && error.extensions.code === 'UNAUTHENTICATED') { … }
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Without `createMaskError`, the message is the shared key translated with
|
|
272
|
+
`@nxgt/i18n`'s own resources (`Could not find the requested resource.`), in
|
|
273
|
+
the request's language when one is known. A custom `message:` key those
|
|
274
|
+
resources do not hold reaches the client as the key: register
|
|
275
|
+
`createMaskError(translate)` to translate it with yours. `denial` and
|
|
276
|
+
`denialMessageKey` are exported for a resolver or a mask of your own.
|