@hivemagnus/hive-common 0.1.0 → 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/CHANGELOG.md +25 -1
- package/README.md +58 -1
- package/dist/context/index.d.ts +13 -1
- package/dist/context/index.js +4 -2
- package/dist/health/index.d.ts +24 -8
- package/dist/health/index.js +15 -2
- package/dist/hono/index.d.ts +6 -3
- package/dist/hono/index.js +4 -1
- package/dist/node-http/index.d.ts +6 -3
- package/dist/node-http/index.js +4 -1
- package/dist/oathweave/claims.d.ts +21 -5
- package/dist/oathweave/claims.js +19 -11
- package/dist/oathweave/index.d.ts +1 -1
- package/dist/oathweave/index.js +1 -1
- package/dist/oathweave/links.d.ts +2 -0
- package/dist/oathweave/links.js +9 -2
- package/dist/oathweave/verifier.d.ts +15 -1
- package/dist/oathweave/verifier.js +13 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ All notable changes to `@hivemagnus/hive-common` are recorded here. The format f
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-11-12
|
|
10
|
+
|
|
11
|
+
Options that let a product keep its existing behaviour without wrapping the shared helpers. Every
|
|
12
|
+
change is additive: the defaults are those of 0.1.0.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `/oathweave`: `requireRoles` (default `false`) refuses a token without a `roles` array.
|
|
17
|
+
- `/oathweave`: `clientIdClaim: 'clientId' | 'client_id' | 'either'` (default `'either'`) chooses
|
|
18
|
+
which claim names supply the client ID. Both claims present must still agree. `clientIdOf()`,
|
|
19
|
+
`principalKindOf()` and `extractRoles()` take matching optional arguments, and `ClaimPolicy`
|
|
20
|
+
matching optional fields. `CLIENT_ID_CLAIMS` and the `ClientIdClaim` type are exported.
|
|
21
|
+
- `/oathweave`: `parseIdentityLinks()` accepts `localId` as an alias of `localUserId`. An entry
|
|
22
|
+
that sets both to different values is refused. Parsed links always use `localUserId`.
|
|
23
|
+
- `/context`: `trustInboundRequestId` and `trustInboundTrace` trust the inbound request ID and the
|
|
24
|
+
inbound `traceparent`/`tracestate` separately. Each defaults to `trustInbound`. The `/hono` and
|
|
25
|
+
`/node-http` adapters take them too.
|
|
26
|
+
- `/health`: `liveBody` replaces the `/health/live` body with a product's own, as an object or a
|
|
27
|
+
function of the standard body. `liveness()`, `createHealth()`, `Health`, `registerHealthRoutes()`
|
|
28
|
+
and `hiveHandler({ health })` accept it; `HealthOptions` and `Health` take an optional type
|
|
29
|
+
parameter for the body, defaulting to `LiveBody`.
|
|
30
|
+
- README: "Choosing settings for an existing product".
|
|
31
|
+
|
|
9
32
|
## [0.1.0] - 2026-11-12
|
|
10
33
|
|
|
11
34
|
First release: the shared server kit that replaces each product's own copy of the Oathweave
|
|
@@ -37,5 +60,6 @@ verifier and its own error, health and request-ID conventions.
|
|
|
37
60
|
pagination and agent identity.
|
|
38
61
|
- `/hono` and `/node-http` adapters for auth, request context, the error envelope and health.
|
|
39
62
|
|
|
40
|
-
[Unreleased]: https://github.com/ChefTaco/HiveCommon/compare/v0.
|
|
63
|
+
[Unreleased]: https://github.com/ChefTaco/HiveCommon/compare/v0.2.0...HEAD
|
|
64
|
+
[0.2.0]: https://github.com/ChefTaco/HiveCommon/compare/v0.1.0...v0.2.0
|
|
41
65
|
[0.1.0]: https://github.com/ChefTaco/HiveCommon/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -104,7 +104,8 @@ refetched, at most every 30 s, when a token names an unknown `kid`); `iss` is on
|
|
|
104
104
|
|
|
105
105
|
- **User:** `actorType: "USER"`, a `sessionId`, a `clientId` other than the subject, string-array
|
|
106
106
|
`permissions` and (optional) `roles`. With `userinfoUrl`, the session is confirmed online; the
|
|
107
|
-
answer is shared for 10 s from when the request started (failures for 2 s)
|
|
107
|
+
answer is shared for 10 s from when the request started (failures for 2 s), or not at all with
|
|
108
|
+
`sessionCacheTtlMs: 0`.
|
|
108
109
|
- **Service:** `clientId` (or `client_id`) equals `sub`, `actorType: "SERVICE_ACCOUNT"`,
|
|
109
110
|
`token_type: "access"`, no session.
|
|
110
111
|
|
|
@@ -129,6 +130,18 @@ const watch = watchStreamAuthorization({
|
|
|
129
130
|
});
|
|
130
131
|
```
|
|
131
132
|
|
|
133
|
+
Two options make the claim rules stricter for products that always required more:
|
|
134
|
+
|
|
135
|
+
- `requireRoles: true` refuses a token without a `roles` array. The default (`false`) treats an
|
|
136
|
+
absent `roles` claim as no roles. A `roles` claim that is not a string array is always refused.
|
|
137
|
+
- `clientIdClaim` chooses which claim names supply the client ID: `'clientId'` (Oathweave),
|
|
138
|
+
`'client_id'` (RFC 9068) or `'either'` (the default). When both claims are present they must
|
|
139
|
+
agree in every mode.
|
|
140
|
+
|
|
141
|
+
Identity links name the local account `localUserId`. `localId`, the name older deployment configs
|
|
142
|
+
use, is accepted as an alias; an entry that sets both to different values is refused. Parsed links
|
|
143
|
+
always carry `localUserId`.
|
|
144
|
+
|
|
132
145
|
Endpoints must be `https`. For tests against a loopback server, pass `allowInsecureLoopback: true`.
|
|
133
146
|
|
|
134
147
|
### Conformance vectors
|
|
@@ -205,6 +218,16 @@ A check is down when it throws, returns `false` or `{ ok: false }`, or exceeds i
|
|
|
205
218
|
`signal` aborts). Error text is hidden unless `exposeErrors: true`. Serve legacy aliases only
|
|
206
219
|
while old clients still call them.
|
|
207
220
|
|
|
221
|
+
A product that has published its own liveness body can keep it with `liveBody`: an object served
|
|
222
|
+
as is, or a function that receives the standard body on every request. Status, headers and
|
|
223
|
+
readiness are unchanged, and liveness still never runs checks. `liveness()`, `createHealth()`,
|
|
224
|
+
`registerHealthRoutes()` and `hiveHandler({ health })` all take it.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
createHealth({ service: 'studio', liveBody: { status: 'ok' } });
|
|
228
|
+
createHealth({ service: 'hive-skills', liveBody: standard => ({ status: 'ok', service: standard.service, protocol: 1 }) });
|
|
229
|
+
```
|
|
230
|
+
|
|
208
231
|
### context
|
|
209
232
|
|
|
210
233
|
```ts
|
|
@@ -221,6 +244,12 @@ Also `parseTraceparent()`, `formatTraceparent()`, `generateTraceparent()`, `chil
|
|
|
221
244
|
`readRequestId()`, `requireIdempotencyKey()` (428 when missing). On an edge service exposed to the
|
|
222
245
|
internet, pass `trustInbound: false` so callers cannot choose IDs.
|
|
223
246
|
|
|
247
|
+
`trustInboundRequestId` and `trustInboundTrace` decide the request ID and the trace separately; each
|
|
248
|
+
defaults to `trustInbound` (itself `true`). An internet-facing service that should still join its
|
|
249
|
+
callers' traces passes `{ trustInboundRequestId: false, trustInboundTrace: true }`. The `/hono`
|
|
250
|
+
`requestContext()` middleware and the `/node-http` `hiveHandler()` and `requestContextFor()` take
|
|
251
|
+
the same options.
|
|
252
|
+
|
|
224
253
|
### registry
|
|
225
254
|
|
|
226
255
|
```ts
|
|
@@ -366,6 +395,34 @@ Behaviour that changes when a product adopts the shared verifier:
|
|
|
366
395
|
- Duplicate identity links fail at startup by default (`duplicates: 'ignore'` keeps the old
|
|
367
396
|
Magnus behaviour of granting nothing to that identity).
|
|
368
397
|
|
|
398
|
+
## Choosing settings for an existing product
|
|
399
|
+
|
|
400
|
+
The defaults suit a new service. A product moving from its own code to HiveCommon can keep its
|
|
401
|
+
existing behaviour with these settings instead of wrapping the shared helpers:
|
|
402
|
+
|
|
403
|
+
| Setting | Default | Set it to keep |
|
|
404
|
+
| --- | --- | --- |
|
|
405
|
+
| `clockToleranceSec: 0` (verifier) | 30 | no clock skew allowance on `exp`, `nbf` and `iat` |
|
|
406
|
+
| `sessionCacheTtlMs: 0` (verifier) | 10 000 | an online session check on every request, so sign-out is immediate |
|
|
407
|
+
| `requireRoles: true` (verifier) | `false` | refusing tokens without a `roles` array |
|
|
408
|
+
| `clientIdClaim: 'clientId'` (verifier) | `'either'` | requiring the Oathweave `clientId` claim; RFC 9068 `client_id` alone is refused |
|
|
409
|
+
| `trustInboundRequestId: false` with `trustInboundTrace: true` (context, `/hono`, `/node-http`) | both `trustInbound` | generating every request ID on an internet-facing service while continuing callers' traces |
|
|
410
|
+
| `liveBody` (health, `/hono`, `/node-http`) | the standard body | a published `/health/live` body such as `{ status: 'ok' }` |
|
|
411
|
+
| `localId` in identity links | `localUserId` | existing link configs, unchanged |
|
|
412
|
+
| `duplicates: 'ignore'` (identity links) | `'reject'` | granting nothing to a duplicated identity instead of failing at startup |
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
const verifier = createOathweaveVerifier({
|
|
416
|
+
issuer, audience: 'chattermost', jwksUrl, userinfoUrl,
|
|
417
|
+
clockToleranceSec: 0, sessionCacheTtlMs: 0, requireRoles: true, clientIdClaim: 'clientId',
|
|
418
|
+
});
|
|
419
|
+
app.use(requestContext({ trustInboundRequestId: false, trustInboundTrace: true }));
|
|
420
|
+
registerHealthRoutes(app, { service: 'chattermost', liveBody: { status: 'ok' } });
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Every refusal, including 503 `auth_unavailable`, is still an `OathweaveError`; a product that
|
|
424
|
+
answers 401 for all of them maps the error in its own handler.
|
|
425
|
+
|
|
369
426
|
## Development
|
|
370
427
|
|
|
371
428
|
```bash
|
package/dist/context/index.d.ts
CHANGED
|
@@ -66,9 +66,21 @@ export interface RequestContext {
|
|
|
66
66
|
export interface CreateRequestContextOptions {
|
|
67
67
|
/**
|
|
68
68
|
* Accept the inbound request ID and trace context. Defaults to true; set false on an edge
|
|
69
|
-
* service that must not let the public internet choose its IDs.
|
|
69
|
+
* service that must not let the public internet choose its IDs. The two settings below
|
|
70
|
+
* override it for one of the two.
|
|
70
71
|
*/
|
|
71
72
|
readonly trustInbound?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Accept an inbound `x-request-id` (or `x-hive-request-id`, `x-correlation-id`). Defaults to
|
|
75
|
+
* `trustInbound`. When false, every request gets a generated ID.
|
|
76
|
+
*/
|
|
77
|
+
readonly trustInboundRequestId?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Continue an inbound `traceparent` and `tracestate`. Defaults to `trustInbound`. When false,
|
|
80
|
+
* every request starts a new trace. An internet-facing service can set this true with
|
|
81
|
+
* `trustInboundRequestId: false` to join a caller's trace while choosing its own request IDs.
|
|
82
|
+
*/
|
|
83
|
+
readonly trustInboundTrace?: boolean;
|
|
72
84
|
readonly generateRequestId?: () => string;
|
|
73
85
|
readonly now?: () => number;
|
|
74
86
|
}
|
package/dist/context/index.js
CHANGED
|
@@ -147,8 +147,10 @@ export function requireIdempotencyKey(headers) {
|
|
|
147
147
|
}
|
|
148
148
|
export function createRequestContext(headers, options = {}) {
|
|
149
149
|
const trust = options.trustInbound ?? true;
|
|
150
|
-
const
|
|
151
|
-
const
|
|
150
|
+
const trustRequestId = options.trustInboundRequestId ?? trust;
|
|
151
|
+
const trustTrace = options.trustInboundTrace ?? trust;
|
|
152
|
+
const requestId = (trustRequestId ? readRequestId(headers) : undefined) ?? (options.generateRequestId ?? generateRequestId)();
|
|
153
|
+
const parentTrace = trustTrace ? parseTraceparent(readHeader(headers, HIVE_HEADERS.traceparent)) : undefined;
|
|
152
154
|
const rawState = parentTrace ? readHeader(headers, HIVE_HEADERS.tracestate)?.trim() : undefined;
|
|
153
155
|
// eslint-disable-next-line no-control-regex
|
|
154
156
|
const tracestate = rawState && rawState.length <= 512 && !/[\u0000-\u001f\u007f]/.test(rawState) ? rawState : undefined;
|
package/dist/health/index.d.ts
CHANGED
|
@@ -51,7 +51,13 @@ export interface HealthResponse<B> {
|
|
|
51
51
|
readonly body: B;
|
|
52
52
|
readonly headers: Readonly<Record<string, string>>;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A product's own liveness body, in place of the standard `LiveBody`: an object served as is,
|
|
56
|
+
* or a function called on every liveness request with the standard body.
|
|
57
|
+
*/
|
|
58
|
+
export type LiveBodyOverride<L extends object> = L | ((standard: LiveBody) => L);
|
|
59
|
+
/** `L` is the liveness body type: `LiveBody` unless `liveBody` is set. */
|
|
60
|
+
export interface HealthOptions<L extends object = LiveBody> {
|
|
55
61
|
readonly service?: string;
|
|
56
62
|
readonly version?: string;
|
|
57
63
|
readonly checks?: readonly HealthCheck[];
|
|
@@ -65,21 +71,31 @@ export interface HealthOptions {
|
|
|
65
71
|
/** Also serve the legacy paths in `LEGACY_HEALTH_ALIASES`, or a custom alias map. */
|
|
66
72
|
readonly aliases?: boolean | Partial<Readonly<Record<HealthKind, readonly string[]>>>;
|
|
67
73
|
readonly now?: () => number;
|
|
74
|
+
/**
|
|
75
|
+
* Serve this body from `/health/live` instead of the standard one, for a product that keeps
|
|
76
|
+
* a published liveness body such as `{ status: 'ok' }`. The status (200) and headers are
|
|
77
|
+
* unchanged, and liveness still never runs checks. Readiness is not affected.
|
|
78
|
+
*/
|
|
79
|
+
readonly liveBody?: LiveBodyOverride<L>;
|
|
68
80
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The liveness response: 200 whenever the process can answer. It never runs checks. The body
|
|
83
|
+
* is the standard `LiveBody`, or the product's own when `liveBody` is set.
|
|
84
|
+
*/
|
|
85
|
+
export declare function liveness<L extends object = LiveBody>(options?: HealthOptions<L>): HealthResponse<L>;
|
|
71
86
|
/** Run every check concurrently and build the readiness response: 503 if a critical check is down. */
|
|
72
|
-
export declare function readiness(options?: HealthOptions): Promise<HealthResponse<ReadyBody>>;
|
|
87
|
+
export declare function readiness(options?: HealthOptions<object>): Promise<HealthResponse<ReadyBody>>;
|
|
73
88
|
/** Which health endpoint a path names, including legacy aliases when enabled. */
|
|
74
89
|
export declare function resolveHealthPath(pathname: string, aliases?: HealthOptions['aliases']): HealthKind | undefined;
|
|
75
90
|
/** All paths served for each kind, standard first. */
|
|
76
91
|
export declare function healthPaths(aliases?: HealthOptions['aliases']): Readonly<Record<HealthKind, readonly string[]>>;
|
|
77
|
-
|
|
78
|
-
|
|
92
|
+
/** `L` is the liveness body type: `LiveBody` unless `liveBody` was set. */
|
|
93
|
+
export interface Health<L extends object = LiveBody> {
|
|
94
|
+
live(): HealthResponse<L>;
|
|
79
95
|
ready(): Promise<HealthResponse<ReadyBody>>;
|
|
80
96
|
/** The response for a health path, or undefined when the path is not a health path. */
|
|
81
|
-
handle(pathname: string): Promise<HealthResponse<
|
|
97
|
+
handle(pathname: string): Promise<HealthResponse<L | ReadyBody>> | undefined;
|
|
82
98
|
readonly paths: Readonly<Record<HealthKind, readonly string[]>>;
|
|
83
99
|
}
|
|
84
100
|
/** Bind options once and serve both endpoints. */
|
|
85
|
-
export declare function createHealth(options?: HealthOptions): Health
|
|
101
|
+
export declare function createHealth<L extends object = LiveBody>(options?: HealthOptions<L>): Health<L>;
|
package/dist/health/index.js
CHANGED
|
@@ -12,10 +12,22 @@ const HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-cont
|
|
|
12
12
|
function identity(options) {
|
|
13
13
|
return { ...(options.service ? { service: options.service } : {}), ...(options.version ? { version: options.version } : {}) };
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
function assertLiveBody(value) {
|
|
16
|
+
if (value !== undefined && typeof value !== 'function' && (typeof value !== 'object' || value === null || Array.isArray(value))) {
|
|
17
|
+
throw new TypeError('liveBody must be an object or a function');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The liveness response: 200 whenever the process can answer. It never runs checks. The body
|
|
22
|
+
* is the standard `LiveBody`, or the product's own when `liveBody` is set.
|
|
23
|
+
*/
|
|
16
24
|
export function liveness(options = {}) {
|
|
25
|
+
assertLiveBody(options.liveBody);
|
|
17
26
|
const now = options.now ?? Date.now;
|
|
18
|
-
|
|
27
|
+
const standard = { status: 'live', ...identity(options), time: new Date(now()).toISOString() };
|
|
28
|
+
const custom = options.liveBody;
|
|
29
|
+
const body = custom === undefined ? standard : typeof custom === 'function' ? custom(standard) : { ...custom };
|
|
30
|
+
return { status: 200, body: body, headers: HEADERS };
|
|
19
31
|
}
|
|
20
32
|
class CheckTimeout extends Error {
|
|
21
33
|
}
|
|
@@ -97,6 +109,7 @@ function aliasMap(aliases) {
|
|
|
97
109
|
}
|
|
98
110
|
/** Bind options once and serve both endpoints. */
|
|
99
111
|
export function createHealth(options = {}) {
|
|
112
|
+
assertLiveBody(options.liveBody);
|
|
100
113
|
return {
|
|
101
114
|
live: () => liveness(options),
|
|
102
115
|
ready: () => readiness(options),
|
package/dist/hono/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { Context, Env, ErrorHandler, Hono, MiddlewareHandler, NotFoundHandler } from 'hono';
|
|
13
13
|
import { type CreateRequestContextOptions, type RequestContext } from '../context/index.js';
|
|
14
|
-
import { type HealthOptions } from '../health/index.js';
|
|
14
|
+
import { type HealthOptions, type LiveBody } from '../health/index.js';
|
|
15
15
|
import type { Logger } from '../log/index.js';
|
|
16
16
|
import type { AcceptPrincipal, OathweavePrincipal } from '../oathweave/claims.js';
|
|
17
17
|
import { type PermissionOptions } from '../oathweave/links.js';
|
|
@@ -60,5 +60,8 @@ export interface ErrorHandlerOptions {
|
|
|
60
60
|
export declare function errorHandler(options?: ErrorHandlerOptions): ErrorHandler;
|
|
61
61
|
/** `app.notFound(notFoundHandler())`: a 404 envelope. */
|
|
62
62
|
export declare function notFoundHandler(): NotFoundHandler;
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). Set
|
|
65
|
+
* `liveBody` to keep a product's published liveness body.
|
|
66
|
+
*/
|
|
67
|
+
export declare function registerHealthRoutes<E extends Env, L extends object = LiveBody>(app: Hono<E>, options?: HealthOptions<L>): void;
|
package/dist/hono/index.js
CHANGED
|
@@ -91,7 +91,10 @@ export function errorHandler(options = {}) {
|
|
|
91
91
|
export function notFoundHandler() {
|
|
92
92
|
return c => respond(c, HiveError.notFound(), {});
|
|
93
93
|
}
|
|
94
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). Set
|
|
96
|
+
* `liveBody` to keep a product's published liveness body.
|
|
97
|
+
*/
|
|
95
98
|
export function registerHealthRoutes(app, options = {}) {
|
|
96
99
|
const health = createHealth(options);
|
|
97
100
|
const serve = (kind) => async (c) => {
|
|
@@ -24,11 +24,14 @@ export interface SendErrorOptions {
|
|
|
24
24
|
}
|
|
25
25
|
/** Send any thrown value as the error envelope. Non-HiveErrors become a logged, generic 500. */
|
|
26
26
|
export declare function sendError(res: ServerResponse, error: unknown, options?: SendErrorOptions): void;
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Serve a health path. Returns true when `req` was a health request and has been answered.
|
|
29
|
+
* The liveness body is the one `health` was created with (see `liveBody`).
|
|
30
|
+
*/
|
|
31
|
+
export declare function handleHealth(req: IncomingMessage, res: ServerResponse, health: Health<object>): Promise<boolean>;
|
|
29
32
|
export interface HiveHandlerOptions extends CreateRequestContextOptions {
|
|
30
33
|
/** Serve health endpoints before `handler` runs. */
|
|
31
|
-
readonly health?: HealthOptions | Health
|
|
34
|
+
readonly health?: HealthOptions<object> | Health<object>;
|
|
32
35
|
readonly logger?: Pick<Logger, 'error'>;
|
|
33
36
|
/** Echo `x-request-id` on every response. Defaults to true. */
|
|
34
37
|
readonly responseHeader?: boolean;
|
package/dist/node-http/index.js
CHANGED
|
@@ -46,7 +46,10 @@ export function sendError(res, error, options = {}) {
|
|
|
46
46
|
});
|
|
47
47
|
sendJson(res, response.status, response.body, response.headers);
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Serve a health path. Returns true when `req` was a health request and has been answered.
|
|
51
|
+
* The liveness body is the one `health` was created with (see `liveBody`).
|
|
52
|
+
*/
|
|
50
53
|
export async function handleHealth(req, res, health) {
|
|
51
54
|
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
52
55
|
return false;
|
|
@@ -38,18 +38,34 @@ export interface OathweaveServicePrincipal extends PrincipalBase {
|
|
|
38
38
|
readonly kind: 'service';
|
|
39
39
|
}
|
|
40
40
|
export type OathweavePrincipal = OathweaveUserPrincipal | OathweaveServicePrincipal;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Which claim names carry the client ID: Oathweave's `clientId`, RFC 9068 `client_id`, or
|
|
43
|
+
* `either` (the default).
|
|
44
|
+
*/
|
|
45
|
+
export type ClientIdClaim = 'clientId' | 'client_id' | 'either';
|
|
46
|
+
export declare const CLIENT_ID_CLAIMS: readonly ClientIdClaim[];
|
|
47
|
+
/**
|
|
48
|
+
* The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree,
|
|
49
|
+
* whichever name is accepted. With `clientId` or `client_id`, only that claim supplies the value.
|
|
50
|
+
*/
|
|
51
|
+
export declare function clientIdOf(payload: JWTPayload, claim?: ClientIdClaim): string | undefined;
|
|
43
52
|
/** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
|
|
44
53
|
export declare function extractScopes(payload: JWTPayload): string[];
|
|
45
|
-
/**
|
|
46
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Roles; an absent claim is no roles, or invalid when `required` is true. A present claim that
|
|
56
|
+
* is not a string array is invalid.
|
|
57
|
+
*/
|
|
58
|
+
export declare function extractRoles(payload: JWTPayload, required?: boolean): string[] | undefined;
|
|
47
59
|
/** A service token is one whose client ID equals its subject. */
|
|
48
|
-
export declare function principalKindOf(payload: JWTPayload): PrincipalKind;
|
|
60
|
+
export declare function principalKindOf(payload: JWTPayload, claim?: ClientIdClaim): PrincipalKind;
|
|
49
61
|
export interface ClaimPolicy {
|
|
50
62
|
readonly accept: AcceptPrincipal;
|
|
51
63
|
/** Largest allowed `exp - iat`, in seconds. */
|
|
52
64
|
readonly maxLifetimeSec: number;
|
|
65
|
+
/** Refuse a token without a `roles` claim. Defaults to false: an absent claim is no roles. */
|
|
66
|
+
readonly requireRoles?: boolean;
|
|
67
|
+
/** Which claim names supply the client ID. Defaults to `either`. */
|
|
68
|
+
readonly clientIdClaim?: ClientIdClaim;
|
|
53
69
|
}
|
|
54
70
|
/**
|
|
55
71
|
* Check the Oathweave claim rules on a payload whose signature, issuer, audience and time
|
package/dist/oathweave/claims.js
CHANGED
|
@@ -5,13 +5,17 @@ function stringArray(value) {
|
|
|
5
5
|
function nonEmpty(value) {
|
|
6
6
|
return typeof value === 'string' && value.length > 0;
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
export const CLIENT_ID_CLAIMS = ['clientId', 'client_id', 'either'];
|
|
9
|
+
/**
|
|
10
|
+
* The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree,
|
|
11
|
+
* whichever name is accepted. With `clientId` or `client_id`, only that claim supplies the value.
|
|
12
|
+
*/
|
|
13
|
+
export function clientIdOf(payload, claim = 'either') {
|
|
10
14
|
const camel = payload['clientId'];
|
|
11
15
|
const snake = payload['client_id'];
|
|
12
16
|
if (camel !== undefined && snake !== undefined && camel !== snake)
|
|
13
17
|
return undefined;
|
|
14
|
-
const value = camel ?? snake;
|
|
18
|
+
const value = claim === 'clientId' ? camel : claim === 'client_id' ? snake : camel ?? snake;
|
|
15
19
|
return typeof value === 'string' ? value : undefined;
|
|
16
20
|
}
|
|
17
21
|
/** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
|
|
@@ -20,16 +24,19 @@ export function extractScopes(payload) {
|
|
|
20
24
|
const list = typeof raw === 'string' ? raw.split(/\s+/) : stringArray(raw) ? raw : [];
|
|
21
25
|
return [...new Set(list.map(item => item.trim()).filter(Boolean))];
|
|
22
26
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Roles; an absent claim is no roles, or invalid when `required` is true. A present claim that
|
|
29
|
+
* is not a string array is invalid.
|
|
30
|
+
*/
|
|
31
|
+
export function extractRoles(payload, required = false) {
|
|
25
32
|
const roles = payload['roles'];
|
|
26
33
|
if (roles === undefined)
|
|
27
|
-
return [];
|
|
34
|
+
return required ? undefined : [];
|
|
28
35
|
return stringArray(roles) ? [...roles] : undefined;
|
|
29
36
|
}
|
|
30
37
|
/** A service token is one whose client ID equals its subject. */
|
|
31
|
-
export function principalKindOf(payload) {
|
|
32
|
-
const clientId = clientIdOf(payload);
|
|
38
|
+
export function principalKindOf(payload, claim = 'either') {
|
|
39
|
+
const clientId = clientIdOf(payload, claim);
|
|
33
40
|
return clientId !== undefined && clientId === payload.sub ? 'service' : 'user';
|
|
34
41
|
}
|
|
35
42
|
function validAuthTime(value) {
|
|
@@ -51,11 +58,12 @@ export function principalFromClaims(payload, policy) {
|
|
|
51
58
|
if (!nonEmpty(sub) || !nonEmpty(jti) || !nonEmpty(iss))
|
|
52
59
|
throw new OathweaveError('invalid_claims');
|
|
53
60
|
const permissions = payload['permissions'];
|
|
54
|
-
const
|
|
55
|
-
const
|
|
61
|
+
const clientIdClaim = policy.clientIdClaim ?? 'either';
|
|
62
|
+
const roles = extractRoles(payload, policy.requireRoles ?? false);
|
|
63
|
+
const clientId = clientIdOf(payload, clientIdClaim);
|
|
56
64
|
if (!stringArray(permissions) || roles === undefined || clientId === undefined)
|
|
57
65
|
throw new OathweaveError('invalid_claims');
|
|
58
|
-
const kind = principalKindOf(payload);
|
|
66
|
+
const kind = principalKindOf(payload, clientIdClaim);
|
|
59
67
|
if (policy.accept !== 'any' && policy.accept !== kind)
|
|
60
68
|
throw new OathweaveError('wrong_principal_kind', { details: { kind } });
|
|
61
69
|
const base = {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Framework-free; see `/hono` and `/node-http` for middleware.
|
|
4
4
|
*/
|
|
5
5
|
export { createOathweaveVerifier, parseBearerToken, OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC, OATHWEAVE_FAILED_CHECK_TTL_MS, OATHWEAVE_MAX_LIFETIME_SEC, OATHWEAVE_MAX_TOKEN_LENGTH, OATHWEAVE_REQUEST_TIMEOUT_MS, OATHWEAVE_SESSION_CACHE_MAX, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, type OathweaveVerifier, type OathweaveVerifierOptions, type VerifyOptions, } from './verifier.js';
|
|
6
|
-
export { clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, type AcceptPrincipal, type ClaimPolicy, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal, type PrincipalKind, } from './claims.js';
|
|
6
|
+
export { CLIENT_ID_CLAIMS, clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, type AcceptPrincipal, type ClaimPolicy, type ClientIdClaim, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal, type PrincipalKind, } from './claims.js';
|
|
7
7
|
export { OathweaveError, isOathweaveError, statusForReason, type OathweaveFailureReason } from './errors.js';
|
|
8
8
|
export { assertOathweaveEndpoint, endpointProblem, isLoopbackHost, isOathweaveEndpoint, type EndpointOptions } from './endpoint.js';
|
|
9
9
|
export { effectivePermissions, hasAnyRole, hasPermissions, hasScopes, IdentityLinkError, parseIdentityLinks, permissionMatches, requireIdentityLink, requirePermissions, uniqueIdentityLink, type OathweaveIdentityLink, type ParseIdentityLinksOptions, type PermissionOptions, } from './links.js';
|
package/dist/oathweave/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Framework-free; see `/hono` and `/node-http` for middleware.
|
|
4
4
|
*/
|
|
5
5
|
export { createOathweaveVerifier, parseBearerToken, OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC, OATHWEAVE_FAILED_CHECK_TTL_MS, OATHWEAVE_MAX_LIFETIME_SEC, OATHWEAVE_MAX_TOKEN_LENGTH, OATHWEAVE_REQUEST_TIMEOUT_MS, OATHWEAVE_SESSION_CACHE_MAX, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, } from './verifier.js';
|
|
6
|
-
export { clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, } from './claims.js';
|
|
6
|
+
export { CLIENT_ID_CLAIMS, clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, } from './claims.js';
|
|
7
7
|
export { OathweaveError, isOathweaveError, statusForReason } from './errors.js';
|
|
8
8
|
export { assertOathweaveEndpoint, endpointProblem, isLoopbackHost, isOathweaveEndpoint } from './endpoint.js';
|
|
9
9
|
export { effectivePermissions, hasAnyRole, hasPermissions, hasScopes, IdentityLinkError, parseIdentityLinks, permissionMatches, requireIdentityLink, requirePermissions, uniqueIdentityLink, } from './links.js';
|
|
@@ -33,6 +33,8 @@ export interface ParseIdentityLinksOptions {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Parse a JSON array of links, for example from `<PRODUCT>_OATHWEAVE_IDENTITIES_JSON`.
|
|
36
|
+
* An entry may name its local account `localUserId` or, as older configs do, `localId`; an
|
|
37
|
+
* entry that sets both to different values is refused. Parsed links always use `localUserId`.
|
|
36
38
|
* Throws an `IdentityLinkError` naming the first problem; the raw value is never echoed.
|
|
37
39
|
*/
|
|
38
40
|
export declare function parseIdentityLinks(raw: string | unknown, options?: ParseIdentityLinksOptions): OathweaveIdentityLink[];
|
package/dist/oathweave/links.js
CHANGED
|
@@ -2,7 +2,8 @@ import { OathweaveError } from './errors.js';
|
|
|
2
2
|
export class IdentityLinkError extends Error {
|
|
3
3
|
name = 'IdentityLinkError';
|
|
4
4
|
}
|
|
5
|
-
|
|
5
|
+
/** `localId` is accepted as an alias of `localUserId`, the name older deployment configs use. */
|
|
6
|
+
const LINK_FIELDS = new Set(['issuer', 'subject', 'localUserId', 'localId', 'role', 'displayName', 'permissions']);
|
|
6
7
|
function isHttpsUrl(value) {
|
|
7
8
|
if (typeof value !== 'string')
|
|
8
9
|
return false;
|
|
@@ -15,6 +16,8 @@ function isHttpsUrl(value) {
|
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
18
|
* Parse a JSON array of links, for example from `<PRODUCT>_OATHWEAVE_IDENTITIES_JSON`.
|
|
19
|
+
* An entry may name its local account `localUserId` or, as older configs do, `localId`; an
|
|
20
|
+
* entry that sets both to different values is refused. Parsed links always use `localUserId`.
|
|
18
21
|
* Throws an `IdentityLinkError` naming the first problem; the raw value is never echoed.
|
|
19
22
|
*/
|
|
20
23
|
export function parseIdentityLinks(raw, options = {}) {
|
|
@@ -40,7 +43,11 @@ export function parseIdentityLinks(raw, options = {}) {
|
|
|
40
43
|
for (const key of Object.keys(link))
|
|
41
44
|
if (!LINK_FIELDS.has(key))
|
|
42
45
|
throw new IdentityLinkError(`${where} has unknown field ${key.slice(0, 40)}`);
|
|
43
|
-
const { issuer, subject,
|
|
46
|
+
const { issuer, subject, localId, role, displayName, permissions } = link;
|
|
47
|
+
if (link['localUserId'] !== undefined && localId !== undefined && link['localUserId'] !== localId) {
|
|
48
|
+
throw new IdentityLinkError(`${where}: localId and localUserId disagree`);
|
|
49
|
+
}
|
|
50
|
+
const localUserId = link['localUserId'] ?? localId;
|
|
44
51
|
if (!isHttpsUrl(issuer))
|
|
45
52
|
throw new IdentityLinkError(`${where}: issuer must be an https URL`);
|
|
46
53
|
if (typeof subject !== 'string' || !subject || subject.length > 256)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type JSONWebKeySet, type JWTVerifyGetKey } from 'jose';
|
|
2
|
-
import { type AcceptPrincipal, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal } from './claims.js';
|
|
2
|
+
import { type AcceptPrincipal, type ClientIdClaim, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal } from './claims.js';
|
|
3
3
|
import { type EndpointOptions } from './endpoint.js';
|
|
4
4
|
/** How long a userinfo answer is reused, measured from when its request started. */
|
|
5
5
|
export declare const OATHWEAVE_SESSION_CACHE_TTL_MS = 10000;
|
|
@@ -42,8 +42,22 @@ export interface OathweaveVerifierOptions extends EndpointOptions {
|
|
|
42
42
|
readonly jwksCacheMaxAgeMs?: number;
|
|
43
43
|
/** Minimum time between refetches triggered by an unknown `kid` (key rotation). Defaults to 30 s. */
|
|
44
44
|
readonly jwksCooldownMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* How long a userinfo answer is reused, in milliseconds. Defaults to 10 000; 0 checks the
|
|
47
|
+
* session online on every call.
|
|
48
|
+
*/
|
|
45
49
|
readonly sessionCacheTtlMs?: number;
|
|
46
50
|
readonly sessionCacheMax?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Refuse a token without a `roles` array. Defaults to false, where an absent `roles` claim
|
|
53
|
+
* means no roles. A present claim that is not a string array is always refused.
|
|
54
|
+
*/
|
|
55
|
+
readonly requireRoles?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Which claim names supply the client ID: `clientId` (Oathweave), `client_id` (RFC 9068) or
|
|
58
|
+
* `either` (the default). When both claims are present they must agree in every mode.
|
|
59
|
+
*/
|
|
60
|
+
readonly clientIdClaim?: ClientIdClaim;
|
|
47
61
|
}
|
|
48
62
|
export interface VerifyOptions {
|
|
49
63
|
/** Override the verifier's default `accept` for this call. */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { createLocalJWKSet, createRemoteJWKSet, customFetch, decodeProtectedHeader, errors as joseErrors, jwtVerify, } from 'jose';
|
|
10
|
-
import { principalFromClaims, } from './claims.js';
|
|
10
|
+
import { CLIENT_ID_CLAIMS, principalFromClaims, } from './claims.js';
|
|
11
11
|
import { assertOathweaveEndpoint } from './endpoint.js';
|
|
12
12
|
import { OathweaveError } from './errors.js';
|
|
13
13
|
/** How long a userinfo answer is reused, measured from when its request started. */
|
|
@@ -110,6 +110,12 @@ export function createOathweaveVerifier(options) {
|
|
|
110
110
|
throw new TypeError('clockToleranceSec must be 0-300');
|
|
111
111
|
if (!(maxLifetimeSec > 0 && maxLifetimeSec <= 86_400))
|
|
112
112
|
throw new TypeError('maxLifetimeSec must be 1-86400');
|
|
113
|
+
const requireRoles = options.requireRoles ?? false;
|
|
114
|
+
const clientIdClaim = options.clientIdClaim ?? 'either';
|
|
115
|
+
if (typeof requireRoles !== 'boolean')
|
|
116
|
+
throw new TypeError('requireRoles must be a boolean');
|
|
117
|
+
if (!CLIENT_ID_CLAIMS.includes(clientIdClaim))
|
|
118
|
+
throw new TypeError("clientIdClaim must be 'clientId', 'client_id' or 'either'");
|
|
113
119
|
const defaultAccept = options.accept ?? 'user';
|
|
114
120
|
const timeoutMs = options.requestTimeoutMs ?? OATHWEAVE_REQUEST_TIMEOUT_MS;
|
|
115
121
|
const request = options.fetch ?? ((input, init) => fetch(input, init));
|
|
@@ -202,7 +208,12 @@ export function createOathweaveVerifier(options) {
|
|
|
202
208
|
// jose accepts an audience array that contains ours; Oathweave issues exactly one audience.
|
|
203
209
|
if (payload.aud !== options.audience)
|
|
204
210
|
throw new OathweaveError('invalid_audience');
|
|
205
|
-
const principal = principalFromClaims(payload, {
|
|
211
|
+
const principal = principalFromClaims(payload, {
|
|
212
|
+
accept: verifyOptions.accept ?? defaultAccept,
|
|
213
|
+
maxLifetimeSec,
|
|
214
|
+
requireRoles,
|
|
215
|
+
clientIdClaim,
|
|
216
|
+
});
|
|
206
217
|
if (principal.kind === 'user' && options.userinfoUrl) {
|
|
207
218
|
const displayName = await sessionCheck(token, principal.subject);
|
|
208
219
|
return { ...principal, displayName, sessionChecked: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/hive-common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared server kit for the Hive:Magnus suite: Oathweave token verification, error envelope, health, request context, registry client, logging, events and framework adapters",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"hive",
|