@opengeni/events 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/dist/index.d.ts +232 -0
- package/dist/index.js +343 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
- package/src/index.ts +431 -0
- package/src/nats-jwt.ts +335 -0
package/src/nats-jwt.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// packages/events/src/nats-jwt.ts — NATS JWT v2 signing for the auth-callout
|
|
2
|
+
// responder (bring-your-own-compute M-AUTH; dossier §10.1 NATS Accounts per
|
|
3
|
+
// workspace + §17 the isolation smoke).
|
|
4
|
+
//
|
|
5
|
+
// This is the cryptographic core of the auth-callout tenancy boundary. When an
|
|
6
|
+
// external agent connects to NATS presenting its `oge_` enrollment bearer as the
|
|
7
|
+
// connect auth-token, nats-server (configured with `auth_callout`) issues an
|
|
8
|
+
// authorization request on `$SYS.REQ.USER.AUTH`. Our responder (auth-callout.ts)
|
|
9
|
+
// validates the bearer and answers with a SIGNED authorization-response JWT that
|
|
10
|
+
// embeds a SIGNED user JWT scoping the connection to publish/subscribe ONLY
|
|
11
|
+
// `agent.<workspaceId>.>` (+ the reply `_INBOX.>`). That per-subject permission
|
|
12
|
+
// set IS the per-workspace isolation: workspace A's agent literally cannot
|
|
13
|
+
// pub/sub workspace B's subjects (§19 the NATS-Accounts-misconfig leak risk is
|
|
14
|
+
// closed at the JWT-permission layer, not just by subject naming).
|
|
15
|
+
//
|
|
16
|
+
// WHY HAND-ROLL THE JWT ENCODING (vs a dep): the NATS JWT v2 wire format is small,
|
|
17
|
+
// stable, and fully specified (ADR-26 + nats-io/jwt): a base64url header
|
|
18
|
+
// `{"typ":"JWT","alg":"ed25519-nkey"}`, base64url JSON claims whose `jti` is the
|
|
19
|
+
// base32(SHA-512/256(claims-with-blank-jti)), and an ed25519 nkey signature over
|
|
20
|
+
// `header.payload`. nkeys (re-exported by the `nats` package we already depend on)
|
|
21
|
+
// gives us the ed25519 sign primitive; Node `crypto` gives SHA-512/256. So we own
|
|
22
|
+
// the encoding in a few well-tested functions rather than pull an alpha
|
|
23
|
+
// `@nats-io/jwt` (0.0.x) whose nkeys-version compat is uncertain. No `xkey`
|
|
24
|
+
// encryption is used (the bearer is already an authenticated identity claim and
|
|
25
|
+
// the wire is TLS — encryption is an optional ADR-26 hardening, off here).
|
|
26
|
+
//
|
|
27
|
+
// SECURITY: the account SIGNING SEED never leaves this process and is NEVER logged.
|
|
28
|
+
// Callers pass it as a `string` seed; we `fromSeed` it once per sign. The bearer
|
|
29
|
+
// the responder validates is HMAC-verified elsewhere (verifyEnrollmentBearer); this
|
|
30
|
+
// module only mints the scoped NATS credential once identity is proven.
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { nkeys } from "nats";
|
|
34
|
+
|
|
35
|
+
/** The NATS JWT v2 header — constant for every token we mint (ADR-26 / nats-io/jwt:
|
|
36
|
+
* `TokenTypeJwt="JWT"`, `AlgorithmNkey="ed25519-nkey"`). */
|
|
37
|
+
const JWT_HEADER = { typ: "JWT", alg: "ed25519-nkey" } as const;
|
|
38
|
+
|
|
39
|
+
/** NATS user-claim `nats.type` discriminator + `nats.version` for v2 claims. */
|
|
40
|
+
const USER_CLAIM_TYPE = "user";
|
|
41
|
+
const AUTH_RESPONSE_CLAIM_TYPE = "authorization_response";
|
|
42
|
+
const NATS_CLAIM_VERSION = 2;
|
|
43
|
+
|
|
44
|
+
/** A NATS permission set: subject allow/deny lists (ADR-26 `pub`/`sub` →
|
|
45
|
+
* `allow`/`deny`). An empty/undefined list means "no explicit grant" — combined
|
|
46
|
+
* with the agent scope below, the connection can ONLY reach what `allow` lists. */
|
|
47
|
+
export interface NatsPermission {
|
|
48
|
+
allow?: string[];
|
|
49
|
+
deny?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The pub/sub permissions embedded in a user JWT. */
|
|
53
|
+
export interface NatsPermissions {
|
|
54
|
+
pub: NatsPermission;
|
|
55
|
+
sub: NatsPermission;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The minimal nkey keypair surface this module needs — exactly what
|
|
60
|
+
* `nkeys.fromSeed(seed)` returns. Declared structurally so the module does not
|
|
61
|
+
* leak the `nats` nkeys type through its public signature.
|
|
62
|
+
*/
|
|
63
|
+
interface NkeyPair {
|
|
64
|
+
getPublicKey(): string;
|
|
65
|
+
sign(input: Uint8Array): Uint8Array;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** base64url (RawURLEncoding — no padding), matching nats-io/jwt's `serialize`. */
|
|
69
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
70
|
+
return Buffer.from(bytes).toString("base64url");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** RFC 4648 base32 (standard alphabet, NO padding) — the encoding nats-io/jwt
|
|
74
|
+
* uses for the `jti` hash. Node has no built-in base32, so a tiny encoder. */
|
|
75
|
+
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
76
|
+
function base32NoPadding(bytes: Uint8Array): string {
|
|
77
|
+
let bits = 0;
|
|
78
|
+
let value = 0;
|
|
79
|
+
let out = "";
|
|
80
|
+
for (const byte of bytes) {
|
|
81
|
+
value = (value << 8) | byte;
|
|
82
|
+
bits += 8;
|
|
83
|
+
while (bits >= 5) {
|
|
84
|
+
bits -= 5;
|
|
85
|
+
out += BASE32_ALPHABET[(value >>> bits) & 31];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (bits > 0) {
|
|
89
|
+
out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compute the canonical NATS `jti`: base32(NoPadding, std-alphabet) of the
|
|
96
|
+
* SHA-512/256 of the claims object SERIALIZED WITH AN EMPTY `jti` (nats-io/jwt's
|
|
97
|
+
* `hash`). nats-server recomputes + verifies this on decode, so it must match
|
|
98
|
+
* byte-for-byte. We serialize the SAME object we will sign, only with `jti:""`.
|
|
99
|
+
*/
|
|
100
|
+
function computeJti(claimsWithBlankJti: object): string {
|
|
101
|
+
const json = JSON.stringify(claimsWithBlankJti);
|
|
102
|
+
const digest = createHash("sha512-256").update(json, "utf8").digest();
|
|
103
|
+
return base32NoPadding(digest);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Encode + sign a NATS v2 JWT. The `claims` MUST already carry `iss`/`sub`/`iat`
|
|
108
|
+
* (+ optional `aud`/`exp`) and a `nats` block; this function fills `jti` (the
|
|
109
|
+
* canonical hash), serializes `header.payload`, signs that with `signingKey`, and
|
|
110
|
+
* appends the base64url signature. Returns the compact `header.payload.signature`.
|
|
111
|
+
*/
|
|
112
|
+
function encodeJwt(claims: Record<string, unknown>, signingKey: NkeyPair): string {
|
|
113
|
+
// jti is the hash of the claims with jti blanked — set it blank, hash, then set.
|
|
114
|
+
const withBlankJti = { ...claims, jti: "" };
|
|
115
|
+
const jti = computeJti(withBlankJti);
|
|
116
|
+
const finalClaims = { ...claims, jti };
|
|
117
|
+
|
|
118
|
+
const header = base64UrlEncode(Buffer.from(JSON.stringify(JWT_HEADER), "utf8"));
|
|
119
|
+
const payload = base64UrlEncode(Buffer.from(JSON.stringify(finalClaims), "utf8"));
|
|
120
|
+
const signingInput = `${header}.${payload}`;
|
|
121
|
+
const signature = signingKey.sign(Buffer.from(signingInput, "utf8"));
|
|
122
|
+
return `${signingInput}.${base64UrlEncode(signature)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Input to mint a workspace-scoped NATS user JWT for an enrolled agent.
|
|
127
|
+
* - `userPublicKey` — the `user_nkey` from the authorization request; it MUST be
|
|
128
|
+
* the `sub` of the user JWT (nats-server rejects a mismatch).
|
|
129
|
+
* - `accountSeed` — the callout account SIGNING seed (`SA...`); both the user JWT
|
|
130
|
+
* `iss` (its public key) and the signature come from it. NEVER logged.
|
|
131
|
+
* - `name` — a human label for the user (the agent id), for server logs.
|
|
132
|
+
* - `permissions` — the pub/sub allow/deny lists (the workspace scope).
|
|
133
|
+
* - `expiresAtSeconds` — optional absolute `exp` (unix seconds). When set the
|
|
134
|
+
* server will expire the connection's credential; we tie it to the bearer's
|
|
135
|
+
* remaining life so a revoked/expired enrollment cannot outlive its bearer.
|
|
136
|
+
*/
|
|
137
|
+
export interface MintUserJwtInput {
|
|
138
|
+
userPublicKey: string;
|
|
139
|
+
accountSeed: string;
|
|
140
|
+
name: string;
|
|
141
|
+
permissions: NatsPermissions;
|
|
142
|
+
/** The target account NAME (the `auth_callout.account`) the user binds to; the
|
|
143
|
+
* embedded user JWT's `aud` in server-config mode. */
|
|
144
|
+
audienceAccount: string;
|
|
145
|
+
expiresAtSeconds?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Mint a signed NATS user JWT scoped by `permissions`. In auth-callout SERVER
|
|
150
|
+
* mode the user JWT is signed by the callout ISSUER ACCOUNT key, and its `iss` is
|
|
151
|
+
* that account's public key. The returned JWT is embedded as `nats.jwt` in the
|
|
152
|
+
* authorization response.
|
|
153
|
+
*/
|
|
154
|
+
export function mintUserJwt(input: MintUserJwtInput): string {
|
|
155
|
+
const accountKey = nkeys.fromSeed(Buffer.from(input.accountSeed)) as unknown as NkeyPair;
|
|
156
|
+
const accountPublicKey = accountKey.getPublicKey();
|
|
157
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
158
|
+
|
|
159
|
+
const natsBlock: Record<string, unknown> = {
|
|
160
|
+
type: USER_CLAIM_TYPE,
|
|
161
|
+
version: NATS_CLAIM_VERSION,
|
|
162
|
+
pub: input.permissions.pub,
|
|
163
|
+
sub: input.permissions.sub,
|
|
164
|
+
// Unlimited subscriptions / data / payload (the workspace subject scope, NOT
|
|
165
|
+
// a connection-resource quota, is the boundary here).
|
|
166
|
+
subs: -1,
|
|
167
|
+
data: -1,
|
|
168
|
+
payload: -1,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const claims: Record<string, unknown> = {
|
|
172
|
+
jti: "",
|
|
173
|
+
iat: nowSeconds,
|
|
174
|
+
iss: accountPublicKey,
|
|
175
|
+
name: input.name,
|
|
176
|
+
sub: input.userPublicKey,
|
|
177
|
+
// SERVER-config-mode placement: nats-server reads the embedded user JWT's `aud`
|
|
178
|
+
// as the target account NAME (the configured `auth_callout.account`). This is
|
|
179
|
+
// how the authenticated user binds to that account; the workspace isolation is
|
|
180
|
+
// then carried by the pub/sub permissions below.
|
|
181
|
+
aud: input.audienceAccount,
|
|
182
|
+
nats: natsBlock,
|
|
183
|
+
};
|
|
184
|
+
if (typeof input.expiresAtSeconds === "number") {
|
|
185
|
+
claims.exp = input.expiresAtSeconds;
|
|
186
|
+
}
|
|
187
|
+
return encodeJwt(claims, accountKey);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Input to mint the authorization RESPONSE JWT the responder publishes back on the
|
|
192
|
+
* request's reply subject (ADR-26 §3).
|
|
193
|
+
* - `userPublicKey` — the request's `user_nkey`; the response `sub`.
|
|
194
|
+
* - `serverId` — the request's `nats.server_id.id` (the server's public key); the
|
|
195
|
+
* response `aud`.
|
|
196
|
+
* - `accountSeed` — the callout account signing seed; signs the response and is
|
|
197
|
+
* its `iss` (public key). NEVER logged.
|
|
198
|
+
* - `userJwt` — the embedded signed user JWT (omit on a denial).
|
|
199
|
+
* - `error` — a human-readable denial message (omit on success). When present the
|
|
200
|
+
* server denies the connection.
|
|
201
|
+
*/
|
|
202
|
+
export interface MintAuthResponseInput {
|
|
203
|
+
userPublicKey: string;
|
|
204
|
+
serverId: string;
|
|
205
|
+
accountSeed: string;
|
|
206
|
+
userJwt?: string;
|
|
207
|
+
error?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Mint the signed authorization-response JWT. On success it carries the embedded
|
|
212
|
+
* user JWT (`nats.jwt`); on denial it carries `nats.error` and NO user JWT, which
|
|
213
|
+
* makes nats-server refuse the connection. Signed by the callout account key (its
|
|
214
|
+
* public key is `iss`); `sub` is the user_nkey, `aud` is the server id.
|
|
215
|
+
*/
|
|
216
|
+
export function mintAuthResponse(input: MintAuthResponseInput): string {
|
|
217
|
+
const accountKey = nkeys.fromSeed(Buffer.from(input.accountSeed)) as unknown as NkeyPair;
|
|
218
|
+
const accountPublicKey = accountKey.getPublicKey();
|
|
219
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
220
|
+
|
|
221
|
+
const natsBlock: Record<string, unknown> = {
|
|
222
|
+
type: AUTH_RESPONSE_CLAIM_TYPE,
|
|
223
|
+
version: NATS_CLAIM_VERSION,
|
|
224
|
+
};
|
|
225
|
+
if (input.userJwt) {
|
|
226
|
+
natsBlock.jwt = input.userJwt;
|
|
227
|
+
}
|
|
228
|
+
if (input.error) {
|
|
229
|
+
natsBlock.error = input.error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const claims: Record<string, unknown> = {
|
|
233
|
+
jti: "",
|
|
234
|
+
iat: nowSeconds,
|
|
235
|
+
iss: accountPublicKey,
|
|
236
|
+
// The response `aud` MUST be the SERVER public key in server-config mode
|
|
237
|
+
// (nats-server validates "Audience must be a server public key"). The
|
|
238
|
+
// authenticated user is placed into the configured `auth_callout.account` (the
|
|
239
|
+
// SAME account the responder + the privileged control plane connect into), so
|
|
240
|
+
// `agent.<ws>.<id>.rpc` request/reply routes; the workspace isolation is carried
|
|
241
|
+
// entirely by the user JWT's pub/sub subject permissions (NOT by cross-account
|
|
242
|
+
// placement, which server-config-mode nats does not support — nats-io#4335).
|
|
243
|
+
aud: input.serverId,
|
|
244
|
+
sub: input.userPublicKey,
|
|
245
|
+
nats: natsBlock,
|
|
246
|
+
};
|
|
247
|
+
return encodeJwt(claims, accountKey);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The fields the responder needs out of the authorization REQUEST JWT (ADR-26 §2).
|
|
252
|
+
* The request is itself a NATS JWT (`header.payload.signature`) the server signs;
|
|
253
|
+
* we only DECODE it (the server proves its own identity by the connection, and the
|
|
254
|
+
* embedded `auth_token` is independently HMAC-verified), so we read the payload
|
|
255
|
+
* without re-verifying the server signature.
|
|
256
|
+
*/
|
|
257
|
+
export interface DecodedAuthRequest {
|
|
258
|
+
/** The public user nkey the response user JWT MUST be `sub`-scoped to. */
|
|
259
|
+
userNkey: string;
|
|
260
|
+
/** The server's public id — the response `aud`. */
|
|
261
|
+
serverId: string;
|
|
262
|
+
/** The connect `auth_token` the client presented (our `oge_` bearer), if any. */
|
|
263
|
+
authToken: string | undefined;
|
|
264
|
+
/** The connect username, if any (unused today; present for completeness). */
|
|
265
|
+
user: string | undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Decode the authorization-request JWT payload (the middle base64url segment). The
|
|
270
|
+
* request shape (ADR-26 §2): `nats.user_nkey`, `nats.server_id.id`, and the
|
|
271
|
+
* presented connect options under `nats.connect_opts` (`auth_token` / `user`).
|
|
272
|
+
* Returns null on a malformed token so the caller can deny cleanly.
|
|
273
|
+
*/
|
|
274
|
+
export function decodeAuthRequest(token: string): DecodedAuthRequest | null {
|
|
275
|
+
const parts = token.split(".");
|
|
276
|
+
if (parts.length !== 3) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
let payload: unknown;
|
|
280
|
+
try {
|
|
281
|
+
payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (typeof payload !== "object" || payload === null) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const nats = (payload as { nats?: unknown }).nats;
|
|
289
|
+
if (typeof nats !== "object" || nats === null) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const natsObj = nats as {
|
|
293
|
+
user_nkey?: unknown;
|
|
294
|
+
server_id?: { id?: unknown } | unknown;
|
|
295
|
+
connect_opts?: { auth_token?: unknown; user?: unknown } | unknown;
|
|
296
|
+
};
|
|
297
|
+
const userNkey = typeof natsObj.user_nkey === "string" ? natsObj.user_nkey : null;
|
|
298
|
+
if (!userNkey) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const serverIdRaw =
|
|
302
|
+
typeof natsObj.server_id === "object" && natsObj.server_id !== null
|
|
303
|
+
? (natsObj.server_id as { id?: unknown }).id
|
|
304
|
+
: undefined;
|
|
305
|
+
const serverId = typeof serverIdRaw === "string" ? serverIdRaw : "";
|
|
306
|
+
const connectOpts =
|
|
307
|
+
typeof natsObj.connect_opts === "object" && natsObj.connect_opts !== null
|
|
308
|
+
? (natsObj.connect_opts as { auth_token?: unknown; user?: unknown })
|
|
309
|
+
: {};
|
|
310
|
+
const authToken = typeof connectOpts.auth_token === "string" ? connectOpts.auth_token : undefined;
|
|
311
|
+
const user = typeof connectOpts.user === "string" ? connectOpts.user : undefined;
|
|
312
|
+
return { userNkey, serverId, authToken, user };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Build the workspace-scoped permission set for an agent: it may publish + subscribe
|
|
317
|
+
* ONLY `agent.<workspaceId>.>` (its own RPC/event/hello subtree) and the reply
|
|
318
|
+
* `_INBOX.>` subtree (so request/reply round-trips work). Everything else is
|
|
319
|
+
* implicitly denied (an allow-list with no other entries IS the deny-all-else).
|
|
320
|
+
*
|
|
321
|
+
* THE isolation assertion (§17): with `workspaceId=A`, the returned allow lists name
|
|
322
|
+
* only `agent.A.>` — so a connection bearing this credential is rejected by
|
|
323
|
+
* nats-server the instant it tries to pub/sub `agent.B.>`. This is the per-workspace
|
|
324
|
+
* tenancy boundary, enforced cryptographically by the signed JWT, not by naming.
|
|
325
|
+
*/
|
|
326
|
+
export function workspaceAgentPermissions(workspaceId: string): NatsPermissions {
|
|
327
|
+
const agentScope = `agent.${workspaceId}.>`;
|
|
328
|
+
// The reply-inbox subtree must be reachable for request/reply (the control plane
|
|
329
|
+
// requests on agent.<ws>.<id>.rpc with a reply inbox; the agent responds there).
|
|
330
|
+
const inboxScope = "_INBOX.>";
|
|
331
|
+
return {
|
|
332
|
+
pub: { allow: [agentScope, inboxScope] },
|
|
333
|
+
sub: { allow: [agentScope, inboxScope] },
|
|
334
|
+
};
|
|
335
|
+
}
|