@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 CHANGED
@@ -1,9 +1,326 @@
1
- # kleora-node
1
+ # `@kleora-io/node`
2
2
 
3
- The official Kleora SDK package `@kleora-io/node` (https://kleora.io).
3
+ Access-token verification, the JWKS cache, the `express` and `fastify`
4
+ middlewares, and the typed management client.
4
5
 
5
- This repository is being set up. `0.0.0` is an empty placeholder that reserves the package name; the first real release is `0.1.0`.
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
- Documentation: https://kleora.io/en/docs
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
- Licensed under the Apache License 2.0.
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.