@jami-studio/orchestra 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/LICENSE +159 -0
- package/README.md +21 -0
- package/package.json +33 -0
- package/src/drizzle.mjs +321 -0
- package/src/index.mjs +897 -0
- package/src/oauth.mjs +731 -0
- package/src/schema.mjs +132 -0
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { SignJWT, jwtVerify, errors as joseErrors } from "jose";
|
|
3
|
+
import { createSealedSecretVault } from "./index.mjs";
|
|
4
|
+
|
|
5
|
+
const OAUTH_SCHEMA_VERSION = "2026-06-22.oauth-token-security";
|
|
6
|
+
|
|
7
|
+
// JWS algorithms B4 will verify link tokens against. `none`/unsecured JWTs are never
|
|
8
|
+
// allowed: a link token without a real signature is treated as forged.
|
|
9
|
+
const ALLOWED_LINK_TOKEN_ALGS = Object.freeze([
|
|
10
|
+
"HS256",
|
|
11
|
+
"HS384",
|
|
12
|
+
"HS512",
|
|
13
|
+
"RS256",
|
|
14
|
+
"RS384",
|
|
15
|
+
"RS512",
|
|
16
|
+
"PS256",
|
|
17
|
+
"PS384",
|
|
18
|
+
"PS512",
|
|
19
|
+
"ES256",
|
|
20
|
+
"ES384",
|
|
21
|
+
"ES512",
|
|
22
|
+
"EdDSA",
|
|
23
|
+
]);
|
|
24
|
+
const ALLOWED_LINK_TOKEN_ALG_SET = new Set(ALLOWED_LINK_TOKEN_ALGS);
|
|
25
|
+
const HMAC_ALG_PREFIX = "HS";
|
|
26
|
+
|
|
27
|
+
// jose link-token source lock — re-verified against the official npm registry record.
|
|
28
|
+
export const JOSE_SOURCE_LOCK = Object.freeze({
|
|
29
|
+
schemaVersion: OAUTH_SCHEMA_VERSION,
|
|
30
|
+
packageName: "jose",
|
|
31
|
+
version: "6.2.3",
|
|
32
|
+
license: "MIT",
|
|
33
|
+
evidenceDate: "2026-06-23",
|
|
34
|
+
npmIntegrity: "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
|
35
|
+
repository: "https://github.com/panva/jose",
|
|
36
|
+
officialUrl: "https://www.npmjs.com/package/jose/v/6.2.3",
|
|
37
|
+
exportsVerified: ["SignJWT", "jwtVerify", "errors"],
|
|
38
|
+
apiEvidence:
|
|
39
|
+
"jose@6.2.3 exports SignJWT (compact JWS sign builder), jwtVerify (signature + registered-claim validation with audience/issuer/currentDate), and the errors namespace used to classify rejections.",
|
|
40
|
+
refreshTrigger: "Any jose version change or SignJWT/jwtVerify signature/claim-validation API change.",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Field names that carry raw secret material when they appear inline in a
|
|
44
|
+
// model-facing payload. Mirrors the policy engine's leak detector so the token
|
|
45
|
+
// path and the policy path agree on what "inlined raw secret" means.
|
|
46
|
+
const RAW_SECRET_KEYS = new Set([
|
|
47
|
+
"value",
|
|
48
|
+
"plaintext",
|
|
49
|
+
"tokenvalue",
|
|
50
|
+
"token",
|
|
51
|
+
"accesstoken",
|
|
52
|
+
"refreshtoken",
|
|
53
|
+
"apikey",
|
|
54
|
+
"secret",
|
|
55
|
+
"clientsecret",
|
|
56
|
+
"password",
|
|
57
|
+
"privatekey",
|
|
58
|
+
"bearer",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const SECRET_REF_KEYS = new Set([
|
|
62
|
+
"secretref",
|
|
63
|
+
"secretrefs",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function isRawSecretKey(key) {
|
|
67
|
+
if (typeof key !== "string") return false;
|
|
68
|
+
const norm = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
69
|
+
return RAW_SECRET_KEYS.has(norm);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isSecretRefKey(key) {
|
|
73
|
+
if (typeof key !== "string") return false;
|
|
74
|
+
const norm = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
75
|
+
return SECRET_REF_KEYS.has(norm);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Keys that carry the model-visible context of a run. Secret material must never
|
|
79
|
+
// be inlined into any of these — it must arrive out-of-band by reference only.
|
|
80
|
+
const MODEL_CONTEXT_KEYS = ["prompt", "messages", "input", "context", "payload", "system", "tools", "instructions"];
|
|
81
|
+
|
|
82
|
+
const TOKEN_STATUS = new Set(["active", "rotated", "revoked", "expired"]);
|
|
83
|
+
|
|
84
|
+
export class OAuthTokenSecurityError extends Error {
|
|
85
|
+
constructor(code, message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "OAuthTokenSecurityError";
|
|
88
|
+
this.code = code;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* OAuth/link-token security authority.
|
|
94
|
+
*
|
|
95
|
+
* Stores OAuth tokens encrypted at rest in the orchestra sealed vault and binds every
|
|
96
|
+
* token to an owner, org, audience, and resource. `getOAuthTokens` will only return
|
|
97
|
+
* plaintext to a requester whose ownerId/orgId matches the token's owner and whose
|
|
98
|
+
* requested audience/resource matches the token binding — closing the cross-tenant
|
|
99
|
+
* scoped-read vulnerability. Tokens rotate/revoke on schedule and on scope change.
|
|
100
|
+
*
|
|
101
|
+
* The records are sealed-secret-shaped (secretId/keyVersionId/sealedPayload/metadata) so
|
|
102
|
+
* they persist additively into the existing `orchestra_secrets` table without adding a
|
|
103
|
+
* new control-plane table.
|
|
104
|
+
*/
|
|
105
|
+
export function createOAuthTokenAuthority(options = {}) {
|
|
106
|
+
const now = options.now ?? (() => new Date());
|
|
107
|
+
const vault = options.vault ?? createSealedSecretVault({ now, key: options.key });
|
|
108
|
+
const auditSink = options.auditSink;
|
|
109
|
+
const defaultRotationDays = Number.isFinite(options.rotationAfterDays) ? options.rotationAfterDays : 30;
|
|
110
|
+
const tokens = new Map();
|
|
111
|
+
|
|
112
|
+
function audit(event) {
|
|
113
|
+
const record = {
|
|
114
|
+
schemaVersion: OAUTH_SCHEMA_VERSION,
|
|
115
|
+
recordedAt: now().toISOString(),
|
|
116
|
+
...event,
|
|
117
|
+
};
|
|
118
|
+
auditSink?.write?.(record);
|
|
119
|
+
return record;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function storeOAuthToken(input = {}) {
|
|
123
|
+
const tokenId = normalizeTokenId(input.tokenId);
|
|
124
|
+
const ownerId = requireString(input.ownerId, "ownerId");
|
|
125
|
+
const orgId = requireString(input.orgId, "orgId");
|
|
126
|
+
const audience = requireString(input.audience, "audience");
|
|
127
|
+
const resource = requireString(input.resource, "resource");
|
|
128
|
+
const value = requireString(input.value, "value");
|
|
129
|
+
const scopes = normalizeScopes(input.scopes);
|
|
130
|
+
|
|
131
|
+
// A token whose own metadata inlines a second raw secret is rejected: secrets
|
|
132
|
+
// never travel as plaintext inside other records.
|
|
133
|
+
const inlineLeaks = findInlinedSecretPaths({ metadata: input.metadata, audience, resource });
|
|
134
|
+
if (inlineLeaks.length > 0) {
|
|
135
|
+
audit({ eventType: "oauth.token.store_denied", outcome: "deny", tokenId, ownerId, orgId, reason: "inlined_secret_in_metadata" });
|
|
136
|
+
throw new OAuthTokenSecurityError("inlined_secret_in_metadata", `token metadata inlines raw secret material at ${inlineLeaks.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const aad = `${tokenId}:${ownerId}:${orgId}:${audience}:${resource}`;
|
|
140
|
+
const sealedPayload = vault.seal(value, { secretId: tokenId, aad });
|
|
141
|
+
const issuedAt = input.issuedAt ?? now().toISOString();
|
|
142
|
+
const record = {
|
|
143
|
+
schemaVersion: OAUTH_SCHEMA_VERSION,
|
|
144
|
+
secretId: tokenId,
|
|
145
|
+
tokenId,
|
|
146
|
+
name: input.name ?? tokenId,
|
|
147
|
+
ownerId,
|
|
148
|
+
orgId,
|
|
149
|
+
audience,
|
|
150
|
+
resource,
|
|
151
|
+
scopes,
|
|
152
|
+
keyVersionId: sealedPayload.keyVersionId,
|
|
153
|
+
sealedPayload,
|
|
154
|
+
status: "active",
|
|
155
|
+
issuedAt,
|
|
156
|
+
expiresAt: input.expiresAt,
|
|
157
|
+
rotateAfter: input.rotateAfter ?? rotationDeadline(issuedAt),
|
|
158
|
+
rotatedAt: undefined,
|
|
159
|
+
revokedAt: undefined,
|
|
160
|
+
revokedReason: undefined,
|
|
161
|
+
fingerprint: sha256(value),
|
|
162
|
+
metadata: { kind: "oauth_token", ...sanitizeMetadata(input.metadata) },
|
|
163
|
+
};
|
|
164
|
+
tokens.set(tokenId, record);
|
|
165
|
+
audit({ eventType: "oauth.token.sealed", outcome: "allow", tokenId, ownerId, orgId, audience, resource });
|
|
166
|
+
return publicTokenRecord(record);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Scoped read — the headline B4 fix.
|
|
171
|
+
*
|
|
172
|
+
* Requires the caller's ownerId/orgId AND a target audience. Only tokens whose owner
|
|
173
|
+
* AND org match the requester, whose audience/resource binding matches the request,
|
|
174
|
+
* and that are still active are returned in plaintext. A caller scoped to a different
|
|
175
|
+
* owner/org cannot read another tenant's tokens; an explicit cross-owner tokenId read
|
|
176
|
+
* is denied and audited rather than silently empty.
|
|
177
|
+
*/
|
|
178
|
+
function getOAuthTokens(query = {}) {
|
|
179
|
+
const requesterOwnerId = requireString(query.requesterOwnerId, "requesterOwnerId");
|
|
180
|
+
const requesterOrgId = requireString(query.requesterOrgId, "requesterOrgId");
|
|
181
|
+
// Audience binding is mandatory: a token is only ever returned for the audience it
|
|
182
|
+
// was bound to, so the caller must name the audience it intends to use the token for.
|
|
183
|
+
const audience = requireString(query.audience, "audience");
|
|
184
|
+
const resource = query.resource === undefined ? undefined : requireString(query.resource, "resource");
|
|
185
|
+
const targetTokenId = query.tokenId === undefined ? undefined : normalizeTokenId(query.tokenId);
|
|
186
|
+
const asOf = query.now ? new Date(query.now) : now();
|
|
187
|
+
// Optional least-privilege filter: when the caller declares the scopes it needs, a
|
|
188
|
+
// token is only returned if it actually covers ALL of them. A token minted for a
|
|
189
|
+
// narrower scope is never handed to a caller that needs a broader one (fail-closed).
|
|
190
|
+
const requiredScopes = query.scopes === undefined ? undefined : (Array.isArray(query.scopes) ? query.scopes : [query.scopes]);
|
|
191
|
+
|
|
192
|
+
if (targetTokenId) {
|
|
193
|
+
const target = tokens.get(targetTokenId);
|
|
194
|
+
if (target && !ownerMatches(target, requesterOwnerId, requesterOrgId)) {
|
|
195
|
+
audit({ eventType: "oauth.token.read_denied", outcome: "deny", tokenId: targetTokenId, requesterOwnerId, requesterOrgId, reason: "owner_org_mismatch" });
|
|
196
|
+
throw new OAuthTokenSecurityError("cross_owner_read_denied", `requester ${requesterOwnerId}/${requesterOrgId} cannot read token owned by another owner/org`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const results = [];
|
|
201
|
+
for (const record of tokens.values()) {
|
|
202
|
+
if (targetTokenId && record.tokenId !== targetTokenId) continue;
|
|
203
|
+
if (!ownerMatches(record, requesterOwnerId, requesterOrgId)) continue;
|
|
204
|
+
if (record.audience !== audience) continue;
|
|
205
|
+
if (resource !== undefined && record.resource !== resource) continue;
|
|
206
|
+
if (requiredScopes !== undefined) {
|
|
207
|
+
const recordScopes = new Set(record.scopes);
|
|
208
|
+
if (!requiredScopes.every((s) => recordScopes.has(s))) continue;
|
|
209
|
+
}
|
|
210
|
+
if (record.status !== "active") continue;
|
|
211
|
+
if (isExpired(record, asOf)) {
|
|
212
|
+
record.status = "expired";
|
|
213
|
+
audit({ eventType: "oauth.token.expired", outcome: "deny", tokenId: record.tokenId, ownerId: record.ownerId, orgId: record.orgId });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const aad = `${record.tokenId}:${record.ownerId}:${record.orgId}:${record.audience}:${record.resource}`;
|
|
217
|
+
const value = vault.open(record.sealedPayload, { aad });
|
|
218
|
+
if (record.keyVersionId !== vault.activeKeyVersionId) {
|
|
219
|
+
const resealed = vault.seal(value, { secretId: record.tokenId, aad });
|
|
220
|
+
record.keyVersionId = resealed.keyVersionId;
|
|
221
|
+
record.sealedPayload = resealed;
|
|
222
|
+
tokens.set(record.tokenId, record);
|
|
223
|
+
audit({ eventType: "oauth.token.resealed", outcome: "allow", tokenId: record.tokenId, ownerId: record.ownerId, orgId: record.orgId });
|
|
224
|
+
}
|
|
225
|
+
audit({ eventType: "oauth.token.read", outcome: "allow", tokenId: record.tokenId, ownerId: record.ownerId, orgId: record.orgId, audience, resource: record.resource });
|
|
226
|
+
results.push({
|
|
227
|
+
tokenId: record.tokenId,
|
|
228
|
+
ownerId: record.ownerId,
|
|
229
|
+
orgId: record.orgId,
|
|
230
|
+
audience: record.audience,
|
|
231
|
+
resource: record.resource,
|
|
232
|
+
scopes: [...record.scopes],
|
|
233
|
+
value,
|
|
234
|
+
expiresAt: record.expiresAt,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function rotateOAuthToken(input = {}) {
|
|
241
|
+
const tokenId = normalizeTokenId(input.tokenId);
|
|
242
|
+
const record = requireToken(tokenId);
|
|
243
|
+
assertRequesterOwnsToken(record, input, "rotate");
|
|
244
|
+
const value = requireString(input.value, "value");
|
|
245
|
+
const rotatedAt = input.rotatedAt ?? now().toISOString();
|
|
246
|
+
const aad = `${tokenId}:${record.ownerId}:${record.orgId}:${record.audience}:${record.resource}`;
|
|
247
|
+
const sealedPayload = vault.seal(value, { secretId: tokenId, aad });
|
|
248
|
+
const next = {
|
|
249
|
+
...record,
|
|
250
|
+
keyVersionId: sealedPayload.keyVersionId,
|
|
251
|
+
sealedPayload,
|
|
252
|
+
status: "active",
|
|
253
|
+
rotatedAt,
|
|
254
|
+
rotateAfter: input.rotateAfter ?? rotationDeadline(rotatedAt),
|
|
255
|
+
revokedAt: undefined,
|
|
256
|
+
revokedReason: undefined,
|
|
257
|
+
fingerprint: sha256(value),
|
|
258
|
+
};
|
|
259
|
+
tokens.set(tokenId, next);
|
|
260
|
+
audit({ eventType: "oauth.token.rotated", outcome: "allow", tokenId, ownerId: record.ownerId, orgId: record.orgId });
|
|
261
|
+
return publicTokenRecord(next);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function revokeOAuthToken(input = {}) {
|
|
265
|
+
const tokenId = normalizeTokenId(input.tokenId);
|
|
266
|
+
const record = requireToken(tokenId);
|
|
267
|
+
assertRequesterOwnsToken(record, input, "revoke");
|
|
268
|
+
return revokeRecord(record, input);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Trusted system revoke used by scope-change and lifecycle flows that have already
|
|
272
|
+
// established the owner/org context; never owner/org-gated against a caller.
|
|
273
|
+
function revokeRecord(record, input = {}) {
|
|
274
|
+
const next = {
|
|
275
|
+
...record,
|
|
276
|
+
status: "revoked",
|
|
277
|
+
revokedAt: input.revokedAt ?? now().toISOString(),
|
|
278
|
+
revokedReason: input.reason ?? "revoked",
|
|
279
|
+
};
|
|
280
|
+
tokens.set(next.tokenId, next);
|
|
281
|
+
audit({ eventType: "oauth.token.revoked", outcome: "deny", tokenId: next.tokenId, ownerId: record.ownerId, orgId: record.orgId, reason: next.revokedReason });
|
|
282
|
+
return publicTokenRecord(next);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Revoke every active token for an owner/org whose scopes are no longer covered by the
|
|
287
|
+
* new scope grant. A scope downgrade must immediately invalidate tokens minted under the
|
|
288
|
+
* broader scope.
|
|
289
|
+
*/
|
|
290
|
+
function revokeOnScopeChange(input = {}) {
|
|
291
|
+
const ownerId = requireString(input.ownerId, "ownerId");
|
|
292
|
+
const orgId = requireString(input.orgId, "orgId");
|
|
293
|
+
const newScopes = new Set(normalizeScopes(input.newScopes));
|
|
294
|
+
const revoked = [];
|
|
295
|
+
for (const record of tokens.values()) {
|
|
296
|
+
if (record.status !== "active") continue;
|
|
297
|
+
if (record.ownerId !== ownerId || record.orgId !== orgId) continue;
|
|
298
|
+
const dropped = record.scopes.filter((scope) => !newScopes.has(scope));
|
|
299
|
+
if (dropped.length === 0) continue;
|
|
300
|
+
revoked.push(revokeRecord(record, { reason: `scope_change:${dropped.join(",")}`, revokedAt: input.revokedAt }));
|
|
301
|
+
}
|
|
302
|
+
return revoked;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Schedule sweep: expire tokens past `expiresAt` and surface tokens past their rotation
|
|
307
|
+
* deadline so a scheduler can rotate them. Returns the actioned/flagged tokens.
|
|
308
|
+
*/
|
|
309
|
+
function enforceLifecycle(input = {}) {
|
|
310
|
+
const asOf = input.now ? new Date(input.now) : now();
|
|
311
|
+
const expired = [];
|
|
312
|
+
const dueForRotation = [];
|
|
313
|
+
for (const record of tokens.values()) {
|
|
314
|
+
if (record.status !== "active") continue;
|
|
315
|
+
if (isExpired(record, asOf)) {
|
|
316
|
+
const next = { ...record, status: "expired", revokedAt: asOf.toISOString(), revokedReason: "expired" };
|
|
317
|
+
tokens.set(record.tokenId, next);
|
|
318
|
+
audit({ eventType: "oauth.token.expired", outcome: "deny", tokenId: record.tokenId, ownerId: record.ownerId, orgId: record.orgId });
|
|
319
|
+
expired.push(publicTokenRecord(next));
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (record.rotateAfter && Date.parse(record.rotateAfter) <= asOf.getTime()) {
|
|
323
|
+
dueForRotation.push(publicTokenRecord(record));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { expired, dueForRotation };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function listTokenRecords() {
|
|
330
|
+
return [...tokens.values()].map(publicTokenRecord);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function rotationDeadline(fromIso) {
|
|
334
|
+
const base = Date.parse(fromIso);
|
|
335
|
+
const ms = (Number.isFinite(base) ? base : now().getTime()) + defaultRotationDays * 24 * 60 * 60 * 1000;
|
|
336
|
+
return new Date(ms).toISOString();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function requireToken(tokenId) {
|
|
340
|
+
const record = tokens.get(tokenId);
|
|
341
|
+
if (!record) throw new OAuthTokenSecurityError("unknown_token", `oauth token ${tokenId} does not exist`);
|
|
342
|
+
return record;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Fail-closed write guard: a mutation (rotate/revoke) must carry the requester's
|
|
346
|
+
// ownerId/orgId, and they must match the token's owner. This stops a caller that knows
|
|
347
|
+
// (or guesses) another tenant's tokenId from poisoning it via rotate or DoS-ing it via
|
|
348
|
+
// revoke — the write boundary is enforced just like the read boundary.
|
|
349
|
+
function assertRequesterOwnsToken(record, input, action) {
|
|
350
|
+
const requesterOwnerId = requireString(input.requesterOwnerId, "requesterOwnerId");
|
|
351
|
+
const requesterOrgId = requireString(input.requesterOrgId, "requesterOrgId");
|
|
352
|
+
if (!ownerMatches(record, requesterOwnerId, requesterOrgId)) {
|
|
353
|
+
audit({ eventType: `oauth.token.${action}_denied`, outcome: "deny", tokenId: record.tokenId, requesterOwnerId, requesterOrgId, reason: "owner_org_mismatch" });
|
|
354
|
+
throw new OAuthTokenSecurityError("cross_owner_write_denied", `requester ${requesterOwnerId}/${requesterOrgId} cannot ${action} a token owned by another owner/org`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
schemaVersion: OAUTH_SCHEMA_VERSION,
|
|
360
|
+
storeOAuthToken,
|
|
361
|
+
getOAuthTokens,
|
|
362
|
+
rotateOAuthToken,
|
|
363
|
+
revokeOAuthToken,
|
|
364
|
+
revokeOnScopeChange,
|
|
365
|
+
enforceLifecycle,
|
|
366
|
+
listTokenRecords,
|
|
367
|
+
get vault() {
|
|
368
|
+
return vault;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Sign a link token (compact JWS) with jose. Audience binding is mandatory — a link token
|
|
375
|
+
* is only ever valid for the resource/audience it is minted for.
|
|
376
|
+
*/
|
|
377
|
+
export async function signLinkToken(claims = {}, options = {}) {
|
|
378
|
+
const alg = options.algorithm ?? "HS256";
|
|
379
|
+
assertAllowedAlg(alg);
|
|
380
|
+
const audience = options.audience ?? claims.aud;
|
|
381
|
+
if (!audience) throw new OAuthTokenSecurityError("missing_audience", "link token must be bound to an audience");
|
|
382
|
+
const issuer = options.issuer ?? claims.iss;
|
|
383
|
+
const subject = options.subject ?? claims.sub;
|
|
384
|
+
const key = await resolveKeyMaterial(options.key ?? options.secret, alg, "sign");
|
|
385
|
+
|
|
386
|
+
const builder = new SignJWT(stripReservedClaims(claims))
|
|
387
|
+
.setProtectedHeader({ alg, typ: "JWT" })
|
|
388
|
+
.setAudience(audience);
|
|
389
|
+
if (issuer) builder.setIssuer(issuer);
|
|
390
|
+
if (subject) builder.setSubject(subject);
|
|
391
|
+
if (options.jti) builder.setJti(options.jti);
|
|
392
|
+
|
|
393
|
+
const issuedAt = epochSeconds(options.issuedAt ?? options.now?.() ?? new Date());
|
|
394
|
+
builder.setIssuedAt(issuedAt);
|
|
395
|
+
const expiresAt = options.expiresAt ?? options.expiresInSeconds;
|
|
396
|
+
if (expiresAt !== undefined) {
|
|
397
|
+
builder.setExpirationTime(typeof expiresAt === "number" && expiresAt < 1_000_000_000 ? issuedAt + expiresAt : epochSeconds(expiresAt));
|
|
398
|
+
}
|
|
399
|
+
return builder.sign(key);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Verify a link token with jose. Rejects missing/invalid signatures, `alg:none`, expired
|
|
404
|
+
* tokens, and audience/issuer mismatches. Returns the verified payload on success.
|
|
405
|
+
*/
|
|
406
|
+
export async function verifyLinkToken(token, options = {}) {
|
|
407
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
408
|
+
throw new OAuthTokenSecurityError("malformed_token", "link token must be a non-empty compact JWS string");
|
|
409
|
+
}
|
|
410
|
+
const algorithms = normalizeAlgList(options.algorithms);
|
|
411
|
+
if (!options.audience) {
|
|
412
|
+
throw new OAuthTokenSecurityError("missing_audience", "link token verification requires an expected audience");
|
|
413
|
+
}
|
|
414
|
+
// A single alg is enough to pick symmetric vs asymmetric key handling.
|
|
415
|
+
const key = await resolveKeyMaterial(options.key ?? options.secret, algorithms[0], "verify");
|
|
416
|
+
try {
|
|
417
|
+
const { payload, protectedHeader } = await jwtVerify(token, key, {
|
|
418
|
+
algorithms,
|
|
419
|
+
audience: options.audience,
|
|
420
|
+
issuer: options.issuer,
|
|
421
|
+
subject: options.subject,
|
|
422
|
+
maxTokenAge: options.maxTokenAge,
|
|
423
|
+
clockTolerance: options.clockTolerance ?? 0,
|
|
424
|
+
requiredClaims: options.requiredClaims,
|
|
425
|
+
currentDate: options.now ? new Date(options.now()) : undefined,
|
|
426
|
+
});
|
|
427
|
+
return { valid: true, payload, protectedHeader };
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw mapJoseError(error);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Assert that a run's model-facing context contains no inlined raw secret material or raw
|
|
435
|
+
* secret references. Secret material must arrive out-of-band by `{ secretId }` reference
|
|
436
|
+
* only. Fails closed (throws) by default; pass `{ throwOnViolation: false }` to inspect.
|
|
437
|
+
*/
|
|
438
|
+
export function assertSecretContextIsolation(run = {}, options = {}) {
|
|
439
|
+
const throwOnViolation = options.throwOnViolation !== false;
|
|
440
|
+
const violations = [];
|
|
441
|
+
for (const key of MODEL_CONTEXT_KEYS) {
|
|
442
|
+
if (run[key] === undefined) continue;
|
|
443
|
+
for (const path of findInlinedSecretPaths(run[key], `${key}`)) {
|
|
444
|
+
violations.push({ path, kind: "inlined_secret_value" });
|
|
445
|
+
}
|
|
446
|
+
for (const path of findInlinedSecretRefPaths(run[key], `${key}`)) {
|
|
447
|
+
violations.push({ path, kind: "inlined_secret_ref" });
|
|
448
|
+
}
|
|
449
|
+
for (const path of findTokenShapedStrings(run[key], `${key}`)) {
|
|
450
|
+
violations.push({ path, kind: "token_shaped_string" });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const ok = violations.length === 0;
|
|
454
|
+
if (!ok && throwOnViolation) {
|
|
455
|
+
throw new OAuthTokenSecurityError(
|
|
456
|
+
"inlined_secret_in_context",
|
|
457
|
+
`run model context inlines secret material: ${violations.map((violation) => `${violation.path} (${violation.kind})`).join(", ")}`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
return { ok, violations };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Resolve a run's secret references out-of-band, isolated from the model context.
|
|
465
|
+
*
|
|
466
|
+
* Enforces context isolation first (fail closed), then resolves each `{ secretId }` ref via
|
|
467
|
+
* the provided async/sync resolver into a separate `resolvedSecrets` channel that is NOT
|
|
468
|
+
* merged back into the model-visible context. Refs that carry inlined raw values are rejected.
|
|
469
|
+
*/
|
|
470
|
+
export async function resolveSecretRefsForRun(run = {}, resolver, options = {}) {
|
|
471
|
+
if (typeof resolver !== "function") {
|
|
472
|
+
throw new OAuthTokenSecurityError("missing_resolver", "secret ref resolution requires a resolver function");
|
|
473
|
+
}
|
|
474
|
+
assertSecretContextIsolation(run, options);
|
|
475
|
+
const refs = collectSecretRefs(run.secretRefs ?? run.secret_refs ?? run.secretRef ?? run.secret_ref ?? []);
|
|
476
|
+
const resolvedSecrets = {};
|
|
477
|
+
for (const ref of refs) {
|
|
478
|
+
const hasInlined = Object.keys(ref).some((k) => isRawSecretKey(k) && ref[k] !== undefined && ref[k] !== null && ref[k] !== "");
|
|
479
|
+
if (hasInlined) {
|
|
480
|
+
throw new OAuthTokenSecurityError("inlined_secret_ref", `secret ref ${ref.secretId ?? "<unknown>"} inlines a raw value; pass a reference only`);
|
|
481
|
+
}
|
|
482
|
+
if (!ref.secretId || typeof ref.secretId !== "string") {
|
|
483
|
+
throw new OAuthTokenSecurityError("invalid_secret_ref", "secret ref requires a string secretId");
|
|
484
|
+
}
|
|
485
|
+
resolvedSecrets[ref.secretId] = await resolver(ref);
|
|
486
|
+
}
|
|
487
|
+
return { modelContext: stripSecretChannel(run), resolvedSecrets };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// internals
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
function ownerMatches(record, ownerId, orgId) {
|
|
495
|
+
return safeEqual(record.ownerId, ownerId) && safeEqual(record.orgId, orgId);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function safeEqual(a, b) {
|
|
499
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
500
|
+
const bufferA = Buffer.from(a, "utf8");
|
|
501
|
+
const bufferB = Buffer.from(b, "utf8");
|
|
502
|
+
if (bufferA.length !== bufferB.length) return false;
|
|
503
|
+
return timingSafeEqual(bufferA, bufferB);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function isExpired(record, asOf) {
|
|
507
|
+
if (!record.expiresAt) return false;
|
|
508
|
+
const expiry = Date.parse(record.expiresAt);
|
|
509
|
+
return Number.isFinite(expiry) && expiry <= asOf.getTime();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function publicTokenRecord(record) {
|
|
513
|
+
const { sealedPayload, ...rest } = record;
|
|
514
|
+
return {
|
|
515
|
+
...structuredClone(rest),
|
|
516
|
+
sealedPayload: {
|
|
517
|
+
schemaVersion: sealedPayload.schemaVersion,
|
|
518
|
+
algorithm: sealedPayload.algorithm,
|
|
519
|
+
keyVersionId: sealedPayload.keyVersionId,
|
|
520
|
+
iv: sealedPayload.iv,
|
|
521
|
+
tag: sealedPayload.tag,
|
|
522
|
+
aadHash: sealedPayload.aadHash,
|
|
523
|
+
ciphertext: "[sealed]",
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function findInlinedSecretPaths(value, path = "$") {
|
|
529
|
+
if (Array.isArray(value)) return value.flatMap((child, index) => findInlinedSecretPaths(child, `${path}[${index}]`));
|
|
530
|
+
if (value === null || typeof value !== "object") return [];
|
|
531
|
+
const matches = [];
|
|
532
|
+
for (const [key, child] of Object.entries(value)) {
|
|
533
|
+
const childPath = `${path}.${key}`;
|
|
534
|
+
if (isRawSecretKey(key) && child !== undefined && child !== null && child !== "") {
|
|
535
|
+
matches.push(childPath);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
matches.push(...findInlinedSecretPaths(child, childPath));
|
|
539
|
+
}
|
|
540
|
+
return matches;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function findInlinedSecretRefPaths(value, path = "$") {
|
|
544
|
+
if (Array.isArray(value)) return value.flatMap((child, index) => findInlinedSecretRefPaths(child, `${path}[${index}]`));
|
|
545
|
+
if (value === null || typeof value !== "object") return [];
|
|
546
|
+
const matches = [];
|
|
547
|
+
for (const [key, child] of Object.entries(value)) {
|
|
548
|
+
const childPath = `${path}.${key}`;
|
|
549
|
+
if (isSecretRefKey(key)) {
|
|
550
|
+
for (const ref of asRefArray(child)) {
|
|
551
|
+
if (ref && typeof ref === "object") {
|
|
552
|
+
const hasInlinedSecret = Object.keys(ref).some((k) => isRawSecretKey(k) && ref[k] !== undefined && ref[k] !== null && ref[k] !== "");
|
|
553
|
+
if (hasInlinedSecret) {
|
|
554
|
+
matches.push(childPath);
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
matches.push(...findInlinedSecretRefPaths(child, childPath));
|
|
562
|
+
}
|
|
563
|
+
return matches;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function findTokenShapedStrings(value, path = "$") {
|
|
567
|
+
if (typeof value === "string") return isTokenShaped(value) ? [path] : [];
|
|
568
|
+
if (Array.isArray(value)) return value.flatMap((child, index) => findTokenShapedStrings(child, `${path}[${index}]`));
|
|
569
|
+
if (value === null || typeof value !== "object") return [];
|
|
570
|
+
return Object.entries(value).flatMap(([key, child]) => findTokenShapedStrings(child, `${path}.${key}`));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Detect bearer tokens and JWT-shaped strings inlined into model context.
|
|
574
|
+
function isTokenShaped(value) {
|
|
575
|
+
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/.test(value)) return true;
|
|
576
|
+
if (/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(value)) return true;
|
|
577
|
+
|
|
578
|
+
// Try decoding base64 if it looks like base64
|
|
579
|
+
if (value.length >= 24 && /^[A-Za-z0-9+/=]+$/.test(value)) {
|
|
580
|
+
try {
|
|
581
|
+
const decoded = Buffer.from(value, "base64").toString("utf8");
|
|
582
|
+
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/.test(decoded) || /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(decoded)) {
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Try decoding hex if it looks like hex
|
|
589
|
+
if (value.length >= 32 && /^[a-fA-F0-9]+$/.test(value)) {
|
|
590
|
+
try {
|
|
591
|
+
const decoded = Buffer.from(value, "hex").toString("utf8");
|
|
592
|
+
if (/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/.test(decoded) || /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(decoded)) {
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
} catch {}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Try decoding URL encoding
|
|
599
|
+
if (value.includes("%")) {
|
|
600
|
+
try {
|
|
601
|
+
const decoded = decodeURIComponent(value);
|
|
602
|
+
if (decoded !== value && (/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/.test(decoded) || /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/.test(decoded))) {
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
} catch {}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function collectSecretRefs(refs) {
|
|
612
|
+
return asRefArray(refs).filter((ref) => ref && typeof ref === "object");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function asRefArray(value) {
|
|
616
|
+
if (Array.isArray(value)) return value;
|
|
617
|
+
if (value && typeof value === "object") return [value];
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function stripSecretChannel(run) {
|
|
622
|
+
if (!run || typeof run !== "object") return run;
|
|
623
|
+
const result = {};
|
|
624
|
+
for (const [key, val] of Object.entries(run)) {
|
|
625
|
+
if (!isSecretRefKey(key)) {
|
|
626
|
+
result[key] = val;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return result;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function normalizeScopes(value) {
|
|
633
|
+
return [
|
|
634
|
+
...new Set(
|
|
635
|
+
(Array.isArray(value) ? value : [])
|
|
636
|
+
.filter((scope) => typeof scope === "string" && /^[a-z][a-z0-9:*_.-]*$/.test(scope)),
|
|
637
|
+
),
|
|
638
|
+
].sort();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function sanitizeMetadata(metadata) {
|
|
642
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {};
|
|
643
|
+
return Object.fromEntries(
|
|
644
|
+
Object.entries(metadata).filter(([key]) => !isRawSecretKey(key) && !isSecretRefKey(key)),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function normalizeTokenId(value) {
|
|
649
|
+
const raw = typeof value === "string" && value.length > 0 ? value : `oauth_${randomBytes(8).toString("hex")}`;
|
|
650
|
+
const body = raw
|
|
651
|
+
.toLowerCase()
|
|
652
|
+
.replace(/^oauth_/, "")
|
|
653
|
+
.replace(/[^a-z0-9_-]+/g, "_")
|
|
654
|
+
.replace(/^_+|_+$/g, "")
|
|
655
|
+
.slice(0, 72);
|
|
656
|
+
return `oauth_${body || randomBytes(6).toString("hex")}`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function assertAllowedAlg(alg) {
|
|
660
|
+
if (typeof alg !== "string" || !ALLOWED_LINK_TOKEN_ALG_SET.has(alg)) {
|
|
661
|
+
throw new OAuthTokenSecurityError("unsupported_alg", `link token algorithm ${String(alg)} is not allowed`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function normalizeAlgList(value) {
|
|
666
|
+
const list = Array.isArray(value) && value.length > 0 ? value : ["HS256"];
|
|
667
|
+
for (const alg of list) assertAllowedAlg(alg);
|
|
668
|
+
return [...list];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function resolveKeyMaterial(key, alg, _mode) {
|
|
672
|
+
if (key === undefined || key === null) {
|
|
673
|
+
throw new OAuthTokenSecurityError("missing_key", "link token signing/verification requires a key or secret");
|
|
674
|
+
}
|
|
675
|
+
if (typeof alg === "string" && alg.startsWith(HMAC_ALG_PREFIX)) {
|
|
676
|
+
if (typeof key === "string") return createHash("sha256").update(key).digest();
|
|
677
|
+
if (key instanceof Uint8Array) return key;
|
|
678
|
+
throw new OAuthTokenSecurityError("invalid_symmetric_key", `${alg} requires a string or byte secret`);
|
|
679
|
+
}
|
|
680
|
+
// Asymmetric: jose accepts CryptoKey / KeyObject / JWK directly.
|
|
681
|
+
return key;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function mapJoseError(error) {
|
|
685
|
+
if (error instanceof OAuthTokenSecurityError) return error;
|
|
686
|
+
const code = error?.code;
|
|
687
|
+
if (error instanceof joseErrors.JWSSignatureVerificationFailed || code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
|
|
688
|
+
return new OAuthTokenSecurityError("invalid_signature", "link token signature verification failed");
|
|
689
|
+
}
|
|
690
|
+
if (error instanceof joseErrors.JWTExpired || code === "ERR_JWT_EXPIRED") {
|
|
691
|
+
return new OAuthTokenSecurityError("token_expired", "link token is expired");
|
|
692
|
+
}
|
|
693
|
+
if (error instanceof joseErrors.JWTClaimValidationFailed || code === "ERR_JWT_CLAIM_VALIDATION_FAILED") {
|
|
694
|
+
return new OAuthTokenSecurityError("claim_validation_failed", error.message ?? "link token claim validation failed");
|
|
695
|
+
}
|
|
696
|
+
if (
|
|
697
|
+
error instanceof joseErrors.JWSInvalid ||
|
|
698
|
+
error instanceof joseErrors.JWTInvalid ||
|
|
699
|
+
error instanceof joseErrors.JOSEAlgNotAllowed ||
|
|
700
|
+
code === "ERR_JWS_INVALID" ||
|
|
701
|
+
code === "ERR_JWT_INVALID" ||
|
|
702
|
+
code === "ERR_JOSE_ALG_NOT_ALLOWED"
|
|
703
|
+
) {
|
|
704
|
+
return new OAuthTokenSecurityError("malformed_token", error.message ?? "link token is malformed or uses a disallowed algorithm");
|
|
705
|
+
}
|
|
706
|
+
return new OAuthTokenSecurityError("link_token_verification_failed", error?.message ?? "link token verification failed");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function stripReservedClaims(claims) {
|
|
710
|
+
if (!claims || typeof claims !== "object") return {};
|
|
711
|
+
const { aud, iss, sub, iat, exp, nbf, jti, ...rest } = claims;
|
|
712
|
+
return rest;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function epochSeconds(value) {
|
|
716
|
+
if (typeof value === "number") return value > 1_000_000_000_000 ? Math.floor(value / 1000) : Math.floor(value);
|
|
717
|
+
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
718
|
+
if (!Number.isFinite(ms)) throw new OAuthTokenSecurityError("invalid_time", "expected a Date, epoch, or ISO timestamp");
|
|
719
|
+
return Math.floor(ms / 1000);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function requireString(value, field) {
|
|
723
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
724
|
+
throw new OAuthTokenSecurityError("invalid_input", `${field} is required`);
|
|
725
|
+
}
|
|
726
|
+
return value;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function sha256(value) {
|
|
730
|
+
return `sha256:${createHash("sha256").update(String(value)).digest("hex")}`;
|
|
731
|
+
}
|