@kleora-io/node 0.0.1 → 0.1.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 +322 -5
- package/dist/index.cjs +1057 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +18954 -0
- package/dist/index.d.ts +18954 -0
- package/dist/index.js +1021 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -6
package/README.md
CHANGED
|
@@ -1,9 +1,326 @@
|
|
|
1
|
-
# kleora-node
|
|
1
|
+
# `@kleora-io/node`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Access-token verification, the JWKS cache, the `express` and `fastify`
|
|
4
|
+
middlewares, and the typed management client.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Node ≥ 20, ESM and CJS. The only runtime dependency is a JOSE library;
|
|
7
|
+
`express` and `fastify` are optional peer dependencies, needed only by the
|
|
8
|
+
middleware you actually use.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Kleora's documentation, including how to create an App, register a Client and
|
|
11
|
+
issue an API key, is at https://kleora.io/en/docs.
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @kleora-io/node
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Verifying a token
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { verifyAccessToken } from '@kleora-io/node'
|
|
23
|
+
|
|
24
|
+
const claims = await verifyAccessToken(token, {
|
|
25
|
+
issuer: process.env.KLEORA_ISSUER!, // https://acme.kleora.eu
|
|
26
|
+
audience: 'https://api.example.com', // the environment's api_audience, or the client id
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Everything is checked locally, against the keys the issuer publishes at
|
|
31
|
+
`/.well-known/jwks.json`: the header's `typ` (`at+jwt`, RFC 9068 §4) and `alg`,
|
|
32
|
+
the `ES256` signature, `iss`, `aud`, `exp` and `iat` with 60 s of clock
|
|
33
|
+
tolerance, and the `env` claim against the issuer's own classification.
|
|
34
|
+
`/oauth/userinfo` is never called. A trailing `/` on the configured `issuer`
|
|
35
|
+
is forgiven; the token's own `iss` is matched exactly.
|
|
36
|
+
|
|
37
|
+
A failure is a `TokenError` whose `code` is one of:
|
|
38
|
+
|
|
39
|
+
| Code | Meaning |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `invalid_type` | The header's `typ` is not `at+jwt` — an ID token or a DPoP proof, not an access token. |
|
|
42
|
+
| `malformed` | Not a compact JWS, not `ES256`, no `kid`, or a payload missing a claim the platform always mints. |
|
|
43
|
+
| `invalid_signature` | The signature does not verify against the key the header names. |
|
|
44
|
+
| `issuer_mismatch`, `audience_mismatch`, `expired` | The claim says so. |
|
|
45
|
+
| `unknown_kid` | The issuer's key set was read and does not list that `kid`. |
|
|
46
|
+
| `issuer_unavailable` | The key set could **not** be read: refused, timed out, non-2xx, or unparseable. Not the caller's fault, and the middlewares answer it 503 rather than 401. |
|
|
47
|
+
|
|
48
|
+
A sandbox token presented to a production issuer (or the reverse) is an
|
|
49
|
+
`EnvironmentMismatch`. A configuration this package cannot verify with — no
|
|
50
|
+
issuer, a relative URL, an empty audience, a negative `clockTolerance` — is a
|
|
51
|
+
`KleoraError('invalid_configuration')`, never a token error.
|
|
52
|
+
|
|
53
|
+
### The JWKS cache
|
|
54
|
+
|
|
55
|
+
One cache per issuer, shared across every verification in the process. It
|
|
56
|
+
holds the document for the `max-age` the issuer publishes (300 s), refetches
|
|
57
|
+
at most once every 60 s when a token names a `kid` it does not know, and keeps
|
|
58
|
+
serving a stale set for up to twice the TTL while the endpoint is
|
|
59
|
+
unreachable — then fails closed. Rotations need no refetch at all in the
|
|
60
|
+
ordinary case: the platform publishes the next key before it signs anything.
|
|
61
|
+
|
|
62
|
+
Every fetch sends `User-Agent: kleora-node/{version}` — the same header
|
|
63
|
+
the management client sends, so an operator reading an issuer's access log can
|
|
64
|
+
tell an SDK's key fetch from anything else — and is bounded by a 5 s timeout
|
|
65
|
+
(`JwksCacheOptions.timeout`), and concurrent verifications share one request — which is exactly why the timeout
|
|
66
|
+
matters: without it a single blackholed issuer would hold every request slot in
|
|
67
|
+
your API, not just the first. Intervals are measured on a monotonic clock, so an
|
|
68
|
+
NTP correction cannot wedge the refetch floor.
|
|
69
|
+
|
|
70
|
+
`jwksFor(issuer)` returns the process-wide cache and **only the first call's
|
|
71
|
+
options apply**. Construct a `JwksCache` yourself and pass it as
|
|
72
|
+
`VerifyOptions.jwks` when you need your own settings.
|
|
73
|
+
|
|
74
|
+
## Middlewares
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { express as kleora, requirePermission } from '@kleora-io/node'
|
|
78
|
+
|
|
79
|
+
app.use(kleora({ issuer: process.env.KLEORA_ISSUER!, audience: 'https://api.example.com' }))
|
|
80
|
+
app.get('/invoices', requirePermission('invoices:read'), (req, res) => {
|
|
81
|
+
res.json({ tenant: req.kleora?.tenant })
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import Fastify from 'fastify'
|
|
87
|
+
import { fastify as kleora, requirePermission } from '@kleora-io/node'
|
|
88
|
+
|
|
89
|
+
const app = Fastify()
|
|
90
|
+
await app.register(kleora({ issuer, audience }))
|
|
91
|
+
app.get('/invoices', { preHandler: requirePermission('invoices:read') }, (request) => ({
|
|
92
|
+
tenant: request.kleora?.tenant,
|
|
93
|
+
}))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both answer RFC 9457 problem documents, and the three causes get three answers:
|
|
97
|
+
|
|
98
|
+
| Cause | Answer |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| No credential, or one that does not verify | 401 `unauthenticated`, with `WWW-Authenticate: Bearer` |
|
|
101
|
+
| `requirePermission` and the claim is absent | 403 `insufficient-permission` |
|
|
102
|
+
| The issuer's JWKS could not be read at all | 503 `issuer-unavailable` |
|
|
103
|
+
| This service is misconfigured, or something else went wrong | 500, through your own error handler (`next(error)` / fastify's handler) |
|
|
104
|
+
|
|
105
|
+
Answering 401 to all of them is what makes an outage and a missing environment
|
|
106
|
+
variable look like a fleet of bad credentials — and tells every client to
|
|
107
|
+
discard a good token and re-authenticate against the issuer that is already
|
|
108
|
+
down.
|
|
109
|
+
|
|
110
|
+
`requirePermission` is the same function in both — an express middleware and a
|
|
111
|
+
fastify `preHandler`. On a route the authentication middleware never reached it
|
|
112
|
+
answers 401, not 403.
|
|
113
|
+
|
|
114
|
+
The 401 body says only that the request could not be authenticated; naming the
|
|
115
|
+
check that failed would hand a prober the difference between "no header" and
|
|
116
|
+
"expired". Pass a `logger` to see the reason:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
app.use(kleora({ issuer, audience, logger: (error) => req.log.warn(error) }))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`logger` also receives internal failures; without one they still reach
|
|
123
|
+
`console.error`, because a 500 nobody logged is worse than a noisy log.
|
|
124
|
+
|
|
125
|
+
The middlewares validate their options when you **build** them, so a service
|
|
126
|
+
started without `KLEORA_ISSUER` fails at boot with a message naming the
|
|
127
|
+
problem rather than 401-ing every request.
|
|
128
|
+
|
|
129
|
+
When the server truncated the `permissions` claim at 200 entries
|
|
130
|
+
(`permissions_truncated: true`), a 403 from `requirePermission` may be denying a
|
|
131
|
+
permission the user actually holds; the `detail` says so, and coarser roles are
|
|
132
|
+
the fix.
|
|
133
|
+
|
|
134
|
+
### `request.kleora` in TypeScript
|
|
135
|
+
|
|
136
|
+
**Express needs nothing.** Importing this package declares `kleora?: Claims`
|
|
137
|
+
on express's `Request`, so the example above compiles as written. The
|
|
138
|
+
declaration costs you no dependency: `Express.Request` is a *global* namespace
|
|
139
|
+
that `@types/express` merges from, so with express installed the member appears
|
|
140
|
+
on `req`, and without it the declaration is an unused global and nothing more.
|
|
141
|
+
|
|
142
|
+
It is **optional**, and has to be — a request that never reached the middleware
|
|
143
|
+
genuinely carries no claims, and a non-optional declaration would let a route
|
|
144
|
+
that forgot `app.use(kleora(...))` compile and then read a property of
|
|
145
|
+
`undefined` in production. So `req.kleora?.tenant`, or narrow once:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
if (!req.kleora) return res.status(401).end()
|
|
149
|
+
const { tenant, permissions } = req.kleora // Claims, no `?.` from here on
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Fastify needs three lines**, once, in your application:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import type { Claims } from '@kleora-io/node'
|
|
156
|
+
|
|
157
|
+
declare module 'fastify' {
|
|
158
|
+
interface FastifyRequest { kleora?: Claims }
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The asymmetry is not an oversight. `declare module 'fastify'` is a module
|
|
163
|
+
*augmentation*: TypeScript resolves the name, so shipping it from this package
|
|
164
|
+
would put a `TS2664` — "module 'fastify' cannot be found" — into the `.d.ts` of
|
|
165
|
+
everyone who installed us without fastify, which is most people. The express
|
|
166
|
+
form resolves nothing, so it is safe to ship and is shipped.
|
|
167
|
+
|
|
168
|
+
## The management client
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { ManagementClient } from '@kleora-io/node'
|
|
172
|
+
|
|
173
|
+
const kleora = new ManagementClient({
|
|
174
|
+
apiKey: process.env.KLEORA_API_KEY!, // kl_live_… or kl_test_…
|
|
175
|
+
issuer: 'https://acme-sandbox.kleora.eu', // optional, and worth setting
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const users = await kleora.get('/api/v1/environments/{environment_id}/users', {
|
|
179
|
+
path: { environment_id },
|
|
180
|
+
query: { limit: 50 },
|
|
181
|
+
})
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`get`, `post`, `put`, `patch` and `delete` are typed straight off the
|
|
185
|
+
generated document: the path has to exist for that method, the path parameters
|
|
186
|
+
and body are required when the document says so, and the result is the
|
|
187
|
+
response schema. Nothing is hand-written per endpoint.
|
|
188
|
+
|
|
189
|
+
- **The environment guard.** With `issuer` configured, the key's prefix is
|
|
190
|
+
compared with it **at construction**: a `kl_test_` key aimed at production
|
|
191
|
+
throws `EnvironmentMismatch` from `new ManagementClient(...)`, synchronously,
|
|
192
|
+
before there is an object to call anything on and before any request leaves
|
|
193
|
+
the process. That check needs no network, so it does not wait for one.
|
|
194
|
+
Without `issuer` there is nothing local to compare against, and the guard
|
|
195
|
+
defers to the first call: one `GET /me`, whose `environment.kind` is then
|
|
196
|
+
checked against the prefix and remembered for every later call. Only a
|
|
197
|
+
mismatch is remembered — a transient failure on that `GET /me` is retried on
|
|
198
|
+
the next call rather than poisoning the client. The caller's `requestId`, if
|
|
199
|
+
it supplied one, travels with the guard's request too.
|
|
200
|
+
- **Timeouts.** 10 s by default (`timeout`), on every request including the
|
|
201
|
+
guard's. A caller's own `signal` still works alongside it.
|
|
202
|
+
- **Errors** are `ApiError`, carrying `type`, `status`, `detail`, `errors[]`
|
|
203
|
+
and `requestId`, with the whole problem document plus `Retry-After` and
|
|
204
|
+
`Idempotency-Replayed` under `problem`. `requestId` is the document's
|
|
205
|
+
`request_id`, falling back to the `X-Request-Id` response header when the
|
|
206
|
+
body has none — a 502 an intermediary wrote still names the request to quote
|
|
207
|
+
at support.
|
|
208
|
+
- **The key** is never logged: `toString`, `util.inspect` and `JSON.stringify`
|
|
209
|
+
show only its 16-character prefix.
|
|
210
|
+
- `baseUrl` defaults to `https://api.kleora.io`, and must be an
|
|
211
|
+
absolute `http(s)` URL. A value that already ends in `/api/v1` — the way
|
|
212
|
+
`NUXT_PUBLIC_API_BASE` spells it — is accepted and trimmed.
|
|
213
|
+
|
|
214
|
+
Every request sends `User-Agent: kleora-node/{version}` and forwards
|
|
215
|
+
`X-Request-Id` when the caller supplies one. Nothing is sent anywhere but the
|
|
216
|
+
configured issuer and API base.
|
|
217
|
+
|
|
218
|
+
## The account portal and the self-service API
|
|
219
|
+
|
|
220
|
+
**Both work only for a Client that lists `self_service` in its
|
|
221
|
+
`allowed_scopes`** (that list is the consent). For any other Client
|
|
222
|
+
the portal opens and says that managing the account there is not offered, and no
|
|
223
|
+
token carrying the scope can be issued.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { AccountClient, accountPortalUrl } from '@kleora-io/node'
|
|
227
|
+
|
|
228
|
+
// A link a server renders: a pure function, no request.
|
|
229
|
+
const href = accountPortalUrl({ issuer, clientId, section: 'security', returnTo: 'https://app.example.com/' })
|
|
230
|
+
|
|
231
|
+
// The person's own token — the bearer their request arrived with — not an API key.
|
|
232
|
+
const account = new AccountClient({ issuer, accessToken: bearer })
|
|
233
|
+
const profile = await account.getProfile()
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`returnTo` must be an absolute `http`/`https` URL with no fragment and no
|
|
237
|
+
userinfo, or `KleoraError('invalid_configuration')`; the portal shows it as its
|
|
238
|
+
"back" link only when it equals one of the Client's `post_logout_redirect_uris`.
|
|
239
|
+
Link the portal as a new tab, never a frame (it refuses to be framed).
|
|
240
|
+
`AccountClient` has one method per `/account/v1/*` operation, named for what it
|
|
241
|
+
does; sensitive ones answer `ApiError` `…/reauth-required` until
|
|
242
|
+
`reauthenticate(password)` has run in the last five minutes.
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
pnpm install
|
|
248
|
+
pnpm test # unit suite
|
|
249
|
+
pnpm lint
|
|
250
|
+
pnpm typecheck # also compiles test/express-example.test-d.ts, the README's express example
|
|
251
|
+
pnpm build
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The unit suite starts its own HTTP servers (an issuer publishing a real JWKS,
|
|
255
|
+
a stand-in management API), so it needs nothing running. Every request the
|
|
256
|
+
whole suite makes is recorded, and each file asserts that nothing reached a
|
|
257
|
+
host it did not declare.
|
|
258
|
+
|
|
259
|
+
### The generated client
|
|
260
|
+
|
|
261
|
+
`pnpm generate:client` writes four files from the OpenAPI documents Kleora
|
|
262
|
+
serves. Never edit any of them by hand.
|
|
263
|
+
|
|
264
|
+
| Document | Committed copy | Types |
|
|
265
|
+
| --- | --- | --- |
|
|
266
|
+
| https://api.kleora.io/api/v1/openapi.json (`MANAGEMENT_OPENAPI_URL` overrides it) | `openapi/management-v1.json` | `src/generated/management.ts` |
|
|
267
|
+
| https://id.kleora.eu/account/v1/openapi.json (`ACCOUNT_OPENAPI_URL` overrides it) | `openapi/account-v1.json` | `src/generated/account.ts` |
|
|
268
|
+
|
|
269
|
+
The unit tests read the committed copies, so they hold the hand-written
|
|
270
|
+
wrappers to the documents without needing the network. CI regenerates all four
|
|
271
|
+
files and fails when they differ from what is committed, so a change to the
|
|
272
|
+
deployed API shows up as a red check: run `pnpm generate:client`, adjust
|
|
273
|
+
`src/management.ts` or `src/account.ts` if an operation moved, and commit the
|
|
274
|
+
result.
|
|
275
|
+
|
|
276
|
+
### Contract tests
|
|
277
|
+
|
|
278
|
+
The contract suite in `test/contract/` provisions an App on a live Kleora
|
|
279
|
+
environment, signs a user in through the hosted pages, rotates signing keys and
|
|
280
|
+
calls the management and self-service APIs. **It does not run in CI**: it needs
|
|
281
|
+
a live environment, an account owner's credentials and a Client with a
|
|
282
|
+
registered redirect URI, none of which this repository holds. It skips when any
|
|
283
|
+
of the variables below is absent, so `pnpm test` stays green without one:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
KLEORA_TEST_ISSUER=<issuer> \
|
|
287
|
+
KLEORA_TEST_API_BASE=<management API base>/api/v1 \
|
|
288
|
+
KLEORA_TEST_CLIENT_ID=<client id> \
|
|
289
|
+
KLEORA_TEST_REDIRECT_URI=<a registered redirect URI> \
|
|
290
|
+
KLEORA_TEST_USERNAME=<an account owner's email> \
|
|
291
|
+
KLEORA_TEST_PASSWORD=<their password> \
|
|
292
|
+
pnpm test
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Set `KLEORA_TEST_REQUIRE_LIVE=1` as well to make a missing variable a failure
|
|
296
|
+
rather than a skip.
|
|
297
|
+
|
|
298
|
+
### Releasing
|
|
299
|
+
|
|
300
|
+
Bump `version` in `package.json` (and the literal in `src/version.ts`, which a
|
|
301
|
+
unit test holds equal to it) on `main`, then run the **Release** workflow
|
|
302
|
+
(`.github/workflows/release.yml`) by hand. It builds from a clean install of
|
|
303
|
+
the committed lockfile and publishes to npm by trusted publishing, with
|
|
304
|
+
provenance; no token is stored anywhere.
|
|
305
|
+
|
|
306
|
+
## Licence
|
|
307
|
+
|
|
308
|
+
[Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) —
|
|
309
|
+
the full, unmodified text ships inside this package, so it travels with
|
|
310
|
+
whatever you install. Apache-2.0 rather than a shorter permissive licence
|
|
311
|
+
because of the explicit patent grant in §3, which is the term a legal review of
|
|
312
|
+
a vendor SDK asks about.
|
|
313
|
+
|
|
314
|
+
**There is no `NOTICE` file**, here or in any other Kleora package, and that
|
|
315
|
+
is deliberate. Apache-2.0 §4(d) turns a `NOTICE` into an obligation that
|
|
316
|
+
propagates: every downstream redistributor of a work that has one must
|
|
317
|
+
reproduce its contents. Kleora is the sole copyright holder of the code in
|
|
318
|
+
these packages, so there is nothing to attribute, and a `NOTICE` would place a
|
|
319
|
+
requirement on you in exchange for nothing. Third-party dependencies keep their
|
|
320
|
+
own licences and their own notices; this package redistributes none of them —
|
|
321
|
+
its manifest names them and your installer fetches them.
|
|
322
|
+
|
|
323
|
+
Apache-2.0 covers what you install and compile into your own product: the
|
|
324
|
+
browser, Node, Nuxt and Python SDKs. It does **not** cover the rest of
|
|
325
|
+
Kleora — the hosted service, the API servers, the management console and the
|
|
326
|
+
hosted authentication pages are proprietary.
|