@oxy.so/core 4.0.0 → 4.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/cjs/HttpService.js +142 -33
- package/dist/cjs/api/accounts.js +49 -10
- package/dist/cjs/api/apps.js +2 -2
- package/dist/cjs/api/auth.js +24 -3
- package/dist/cjs/api/billing.js +35 -0
- package/dist/cjs/boot/sessionColdBoot.js +28 -57
- package/dist/cjs/inference/OxyInferenceClient.js +53 -20
- package/dist/cjs/server/OxyServer.js +107 -51
- package/dist/cjs/server/agentAccount.js +19 -0
- package/dist/cjs/server/auth.js +19 -0
- package/dist/cjs/server/capabilityTicket.js +3 -41
- package/dist/cjs/server/index.js +10 -2
- package/dist/cjs/server/liveCapabilityTicket.js +48 -0
- package/dist/cjs/server/middleware.js +60 -18
- package/dist/cjs/server/namespaces.js +57 -0
- package/dist/cjs/server/serviceTokenLifetime.js +13 -0
- package/dist/cjs/session/SessionClient.js +90 -12
- package/dist/cjs/session/authStateStore.js +130 -39
- package/dist/cjs/session/refresh.js +152 -30
- package/dist/cjs/session/sharedDeviceCredential.js +17 -1
- package/dist/cjs/utils/officialOrigins.js +7 -18
- package/dist/esm/HttpService.js +143 -34
- package/dist/esm/api/accounts.js +38 -0
- package/dist/esm/api/apps.js +2 -2
- package/dist/esm/api/auth.js +23 -3
- package/dist/esm/api/billing.js +35 -0
- package/dist/esm/boot/sessionColdBoot.js +29 -58
- package/dist/esm/inference/OxyInferenceClient.js +46 -13
- package/dist/esm/server/OxyServer.js +107 -51
- package/dist/esm/server/agentAccount.js +16 -0
- package/dist/esm/server/auth.js +18 -0
- package/dist/esm/server/capabilityTicket.js +1 -39
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/liveCapabilityTicket.js +45 -0
- package/dist/esm/server/middleware.js +60 -18
- package/dist/esm/server/namespaces.js +57 -0
- package/dist/esm/server/serviceTokenLifetime.js +9 -0
- package/dist/esm/session/SessionClient.js +90 -12
- package/dist/esm/session/authStateStore.js +128 -38
- package/dist/esm/session/refresh.js +151 -30
- package/dist/esm/session/sharedDeviceCredential.js +17 -1
- package/dist/esm/utils/officialOrigins.js +2 -13
- package/dist/types/HttpService.d.ts +26 -12
- package/dist/types/api/accounts.d.ts +38 -0
- package/dist/types/api/agency.d.ts +3 -1
- package/dist/types/api/apps.d.ts +3 -1
- package/dist/types/api/auth.d.ts +12 -1
- package/dist/types/api/billing.d.ts +12 -0
- package/dist/types/api/session.d.ts +3 -1
- package/dist/types/boot/sessionColdBoot.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +12 -0
- package/dist/types/inference/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +16 -9
- package/dist/types/server/agentAccount.d.ts +14 -0
- package/dist/types/server/auth.d.ts +18 -0
- package/dist/types/server/capabilityTicket.d.ts +2 -4
- package/dist/types/server/index.d.ts +7 -1
- package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
- package/dist/types/server/middleware.d.ts +15 -6
- package/dist/types/server/namespaces.d.ts +78 -2
- package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/dist/types/session/authStateStore.d.ts +15 -0
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +32 -7
- package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
- package/dist/types/utils/officialOrigins.d.ts +1 -5
- package/package.json +6 -6
- package/src/HttpService.ts +138 -50
- package/src/api/accounts.ts +22 -0
- package/src/api/agency.ts +3 -1
- package/src/api/apps.ts +2 -2
- package/src/api/auth.ts +31 -2
- package/src/api/billing.ts +71 -9
- package/src/api/session.ts +3 -1
- package/src/boot/sessionColdBoot.ts +27 -72
- package/src/index.ts +1 -1
- package/src/inference/OxyInferenceClient.ts +50 -5
- package/src/inference/index.ts +1 -0
- package/src/server/OxyServer.ts +114 -53
- package/src/server/agentAccount.ts +28 -0
- package/src/server/auth.ts +25 -0
- package/src/server/capabilityTicket.ts +2 -42
- package/src/server/index.ts +13 -0
- package/src/server/liveCapabilityTicket.ts +47 -0
- package/src/server/middleware.ts +72 -25
- package/src/server/namespaces.ts +99 -2
- package/src/server/serviceTokenLifetime.ts +13 -0
- package/src/session/SessionClient.ts +85 -12
- package/src/session/authStateStore.ts +130 -46
- package/src/session/index.ts +1 -0
- package/src/session/refresh.ts +144 -28
- package/src/session/sharedDeviceCredential.ts +14 -2
- package/src/utils/officialOrigins.ts +2 -13
package/src/server/OxyServer.ts
CHANGED
|
@@ -26,6 +26,17 @@
|
|
|
26
26
|
* Node only.
|
|
27
27
|
*/
|
|
28
28
|
import { jwtDecode } from 'jwt-decode';
|
|
29
|
+
import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
|
|
30
|
+
|
|
31
|
+
/** Unverified cache metadata only: an old lifetime forces a new signed mint. */
|
|
32
|
+
function reusableServiceToken(token: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const claims = jwtDecode<{ iat?: number; exp?: number }>(token);
|
|
35
|
+
return hasBoundedServiceTokenLifetime(claims) && typeof claims.exp === 'number'
|
|
36
|
+
&& claims.exp > Math.floor(Date.now() / 1000);
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
30
41
|
import { OxyServices, type OxyConfig } from '../OxyServices';
|
|
31
42
|
import type { HttpMethod, ServiceLane } from '../client/context';
|
|
@@ -87,6 +98,21 @@ export interface OxyServerConfig extends OxyConfig {
|
|
|
87
98
|
export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30_000;
|
|
88
99
|
|
|
89
100
|
/** `POST /auth/service-token`. */
|
|
101
|
+
export interface ActingAsVerificationOptions {
|
|
102
|
+
/** Only explicit read-only callers may reuse authority; effects always pass false. */
|
|
103
|
+
cache?: boolean;
|
|
104
|
+
/** Taken from verified service claims by middleware, never from a user header. */
|
|
105
|
+
credentialId?: string;
|
|
106
|
+
ownerAccountId?: string;
|
|
107
|
+
environment?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ActingAsGeneration {
|
|
111
|
+
epoch: bigint;
|
|
112
|
+
issued: number;
|
|
113
|
+
completed: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
export interface ServiceTokenResponse {
|
|
91
117
|
token: string;
|
|
92
118
|
expiresIn: number;
|
|
@@ -127,10 +153,10 @@ const TOKEN_REUSE_MARGIN_MS = 60_000;
|
|
|
127
153
|
|
|
128
154
|
/** Bound on remembered `appId:userId` delegation answers. */
|
|
129
155
|
const ACTING_AS_CACHE_MAX = 1000;
|
|
130
|
-
/**
|
|
131
|
-
const ACTING_AS_GRANT_TTL_MS =
|
|
132
|
-
/**
|
|
133
|
-
const ACTING_AS_DENIAL_TTL_MS =
|
|
156
|
+
/** Explicit read reuse only; effects always perform a fresh lookup. */
|
|
157
|
+
const ACTING_AS_GRANT_TTL_MS = 60_000;
|
|
158
|
+
/** Explicit authoritative read denials last ten seconds; failures are never cached. */
|
|
159
|
+
const ACTING_AS_DENIAL_TTL_MS = 10_000;
|
|
134
160
|
|
|
135
161
|
export class OxyServer extends OxyServices {
|
|
136
162
|
private readonly serviceTokens = new Map<string, ServiceTokenCacheEntry>();
|
|
@@ -138,6 +164,7 @@ export class OxyServer extends OxyServices {
|
|
|
138
164
|
private serviceApiSecret: string | null = null;
|
|
139
165
|
private readonly actingAs = new Map<string, { result: ServiceActingAsVerification | null; expiresAt: number }>();
|
|
140
166
|
private readonly actingAsPending = new Map<string, Promise<ServiceActingAsVerification | null>>();
|
|
167
|
+
private readonly actingAsGenerations = new Map<string, ActingAsGeneration>();
|
|
141
168
|
/** Public keys only; never private material. */
|
|
142
169
|
private readonly jwksCache: ServiceTokenJwksCache = { keys: new Map(), expiresAt: 0, lastAttemptAt: 0 };
|
|
143
170
|
/**
|
|
@@ -180,7 +207,7 @@ export class OxyServer extends OxyServices {
|
|
|
180
207
|
},
|
|
181
208
|
jwksCache: this.jwksCache,
|
|
182
209
|
validateSession: (sessionId, options) => this.session.validate(sessionId, options),
|
|
183
|
-
verifyActingAs: (appId, userId) => this.verifyActingAs(appId, userId),
|
|
210
|
+
verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
|
|
184
211
|
});
|
|
185
212
|
}
|
|
186
213
|
|
|
@@ -277,7 +304,7 @@ export class OxyServer extends OxyServices {
|
|
|
277
304
|
});
|
|
278
305
|
throw new ServiceCredentialMismatchError();
|
|
279
306
|
}
|
|
280
|
-
if (entry.token && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
|
|
307
|
+
if (entry.token && reusableServiceToken(entry.token) && entry.expiresAt > now + TOKEN_REUSE_MARGIN_MS) {
|
|
281
308
|
return entry.token;
|
|
282
309
|
}
|
|
283
310
|
if (entry.pending) {
|
|
@@ -344,32 +371,45 @@ export class OxyServer extends OxyServices {
|
|
|
344
371
|
// ── Delegation and account events ────────────────────────────────────────
|
|
345
372
|
|
|
346
373
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* revocation window; a denial or failed lookup for 1 minute), bounded to the
|
|
353
|
-
* 1000 most recent pairs, and concurrent lookups of one pair share a single
|
|
354
|
-
* request.
|
|
374
|
+
* Resolve authority live by default. Explicit read-only reuse lasts at most
|
|
375
|
+
* 60s positive / 10s negative, anchored to request start; errors never cache.
|
|
376
|
+
* Effects do not share an older in-flight read. Durable epoch plus local
|
|
377
|
+
* completion ordering prevents ABA and late positive replies overwriting a
|
|
378
|
+
* newer refusal even when app/credential revocation leaves epoch unchanged.
|
|
355
379
|
*/
|
|
356
|
-
async verifyActingAs(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.actingAs.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
380
|
+
async verifyActingAs(
|
|
381
|
+
appId: string, userId: string, options: ActingAsVerificationOptions = {},
|
|
382
|
+
): Promise<ServiceActingAsVerification | null> {
|
|
383
|
+
const pair = JSON.stringify([appId, userId]);
|
|
384
|
+
const cacheKey = JSON.stringify([appId, userId, options.credentialId, options.ownerAccountId, options.environment]);
|
|
385
|
+
if (options.cache === true) {
|
|
386
|
+
const cached = this.actingAs.get(cacheKey);
|
|
387
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
388
|
+
this.actingAs.delete(cacheKey);
|
|
389
|
+
this.actingAs.set(cacheKey, cached);
|
|
390
|
+
return cached.result;
|
|
391
|
+
}
|
|
392
|
+
const pending = this.actingAsPending.get(cacheKey);
|
|
393
|
+
if (pending) return pending;
|
|
394
|
+
}
|
|
395
|
+
let generation = this.actingAsGenerations.get(pair);
|
|
396
|
+
if (!generation) {
|
|
397
|
+
generation = { epoch: 0n, issued: 0, completed: 0 };
|
|
398
|
+
this.actingAsGenerations.set(pair, generation);
|
|
399
|
+
if (this.actingAsGenerations.size > ACTING_AS_CACHE_MAX) {
|
|
400
|
+
const oldest = this.actingAsGenerations.keys().next().value;
|
|
401
|
+
if (oldest !== undefined) this.actingAsGenerations.delete(oldest);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const serial = ++generation.issued;
|
|
405
|
+
const startedAt = Date.now();
|
|
406
|
+
const lookup = this.lookupActingAs(appId, userId, options, pair, cacheKey, generation, serial, startedAt);
|
|
407
|
+
if (options.cache !== true) return lookup;
|
|
371
408
|
this.actingAsPending.set(cacheKey, lookup);
|
|
372
|
-
return lookup;
|
|
409
|
+
try { return await lookup; }
|
|
410
|
+
finally {
|
|
411
|
+
if (this.actingAsPending.get(cacheKey) === lookup) this.actingAsPending.delete(cacheKey);
|
|
412
|
+
}
|
|
373
413
|
}
|
|
374
414
|
|
|
375
415
|
/**
|
|
@@ -457,7 +497,7 @@ export class OxyServer extends OxyServices {
|
|
|
457
497
|
/** A token obtained by attestation, cached like a credential's (one identity per process). */
|
|
458
498
|
private async workloadServiceToken(): Promise<string> {
|
|
459
499
|
const entry = this.serviceTokens.get(WORKLOAD_CACHE_KEY);
|
|
460
|
-
if (entry?.token && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS) return entry.token;
|
|
500
|
+
if (entry?.token && reusableServiceToken(entry.token) && entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS) return entry.token;
|
|
461
501
|
if (entry?.pending) return entry.pending;
|
|
462
502
|
|
|
463
503
|
const seeded = entry ?? { token: '', expiresAt: 0, secretBuf: Buffer.alloc(0), pending: null, apiKey: WORKLOAD_CACHE_KEY };
|
|
@@ -486,41 +526,62 @@ export class OxyServer extends OxyServices {
|
|
|
486
526
|
}
|
|
487
527
|
}
|
|
488
528
|
|
|
489
|
-
private async lookupActingAs(
|
|
529
|
+
private async lookupActingAs(
|
|
530
|
+
appId: string, userId: string, options: ActingAsVerificationOptions,
|
|
531
|
+
pair: string, cacheKey: string, generation: ActingAsGeneration, serial: number, startedAt: number,
|
|
532
|
+
): Promise<ServiceActingAsVerification | null> {
|
|
490
533
|
try {
|
|
491
|
-
// The verify endpoint admits only a platform-trusted caller, so this
|
|
492
|
-
// carries the VERIFIER's own service token. No retry and a short timeout:
|
|
493
|
-
// it runs inside request-handling middleware, and a retry loop would
|
|
494
|
-
// multiply the latency of every delegated request. A verifier with no
|
|
495
|
-
// credential throws here and is refused below — a host that cannot prove
|
|
496
|
-
// who it is has no business learning who delegated to whom.
|
|
497
534
|
const serviceToken = await this.serviceToken();
|
|
498
535
|
const result = await this.request<ServiceActingAsVerification>(
|
|
499
|
-
'GET',
|
|
500
|
-
|
|
501
|
-
|
|
536
|
+
'GET', '/internal/service-acting-as/verify',
|
|
537
|
+
{ appId, userId, ...(options.credentialId ? { credentialId: options.credentialId,
|
|
538
|
+
ownerAccountId: options.ownerAccountId, environment: options.environment } : {}) },
|
|
502
539
|
{ cache: false, retry: false, timeout: 5000, headers: { Authorization: `Bearer ${serviceToken}` } },
|
|
503
540
|
);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
541
|
+
// A deadline bounds how long an already old response can authorize. It
|
|
542
|
+
// is not by itself a measurement of end-to-end revocation latency.
|
|
543
|
+
if (this.actingAsGenerations.get(pair) !== generation || serial < generation.completed) return null;
|
|
544
|
+
generation.completed = serial;
|
|
545
|
+
this.clearActingAsPair(appId, userId);
|
|
546
|
+
if (Date.now() - startedAt >= 5000) return null;
|
|
547
|
+
const rawEpoch = result?.epoch;
|
|
548
|
+
if (typeof rawEpoch !== 'string' || !/^(?:0|[1-9][0-9]{0,18})$/.test(rawEpoch)) return null;
|
|
549
|
+
const epoch = BigInt(rawEpoch);
|
|
550
|
+
if (epoch > 9223372036854775807n) return null;
|
|
551
|
+
if (typeof result.authorized !== 'boolean' || !Array.isArray(result.scopes)
|
|
552
|
+
|| !result.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope.trim() === scope)
|
|
553
|
+
|| new Set(result.scopes).size !== result.scopes.length
|
|
554
|
+
|| (!result.authorized && result.scopes.length !== 0)) return null;
|
|
555
|
+
const older = epoch < generation.epoch;
|
|
556
|
+
if (epoch > generation.epoch) generation.epoch = epoch;
|
|
557
|
+
if (result.authorized && older) return null;
|
|
558
|
+
const verified: ServiceActingAsVerification | null = result.authorized === true
|
|
559
|
+
? { authorized: true, scopes: result.scopes, epoch: rawEpoch } : null;
|
|
560
|
+
if (options.cache === true) this.rememberActingAs(cacheKey, verified,
|
|
561
|
+
startedAt + (verified ? ACTING_AS_GRANT_TTL_MS : ACTING_AS_DENIAL_TTL_MS));
|
|
508
562
|
return verified;
|
|
509
563
|
} catch (error) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
564
|
+
if (this.actingAsGenerations.get(pair) === generation && serial >= generation.completed) {
|
|
565
|
+
generation.completed = serial;
|
|
566
|
+
this.clearActingAsPair(appId, userId);
|
|
567
|
+
}
|
|
568
|
+
logger.warn('[oxy.auth] verifyActingAs lookup failed — not cached', {
|
|
569
|
+
component: 'auth', method: 'verifyActingAs', appId, userId,
|
|
515
570
|
}, error);
|
|
516
|
-
this.rememberActingAs(`${appId}:${userId}`, null, ACTING_AS_DENIAL_TTL_MS);
|
|
517
571
|
return null;
|
|
518
572
|
}
|
|
519
573
|
}
|
|
520
574
|
|
|
521
|
-
private
|
|
575
|
+
private clearActingAsPair(appId: string, userId: string): void {
|
|
576
|
+
for (const key of this.actingAs.keys()) {
|
|
577
|
+
const [cachedApp, cachedUser] = JSON.parse(key) as [string, string];
|
|
578
|
+
if (cachedApp === appId && cachedUser === userId) this.actingAs.delete(key);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private rememberActingAs(cacheKey: string, result: ServiceActingAsVerification | null, expiresAt: number): void {
|
|
522
583
|
this.actingAs.delete(cacheKey);
|
|
523
|
-
this.actingAs.set(cacheKey, { result, expiresAt
|
|
584
|
+
this.actingAs.set(cacheKey, { result, expiresAt });
|
|
524
585
|
if (this.actingAs.size > ACTING_AS_CACHE_MAX) {
|
|
525
586
|
const oldest = this.actingAs.keys().next().value;
|
|
526
587
|
if (oldest !== undefined) this.actingAs.delete(oldest);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Autonomous entry helper. The signer owns its private key (for example in a KMS). */
|
|
2
|
+
import { buildAgentProofMessage, type LoginResult } from '@oxy.so/contracts';
|
|
3
|
+
import { normalizeSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
4
|
+
import type { OxyServices } from '../OxyServices';
|
|
5
|
+
|
|
6
|
+
export interface AgentAccountSigner {
|
|
7
|
+
/** Compressed or uncompressed SEC1 secp256k1 public key; no private-key field exists. */
|
|
8
|
+
publicKey: string;
|
|
9
|
+
signMessage(message: string): Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Explicit sign-in; no timer, automatic escalation or service credential lane. */
|
|
13
|
+
export async function signInAgentAccount(options: {
|
|
14
|
+
client: Pick<OxyServices, 'auth'>;
|
|
15
|
+
accountId: string;
|
|
16
|
+
signer: AgentAccountSigner;
|
|
17
|
+
}): Promise<LoginResult> {
|
|
18
|
+
const { client, accountId, signer } = options;
|
|
19
|
+
const publicKey = normalizeSecp256k1PublicKey(signer.publicKey.trim());
|
|
20
|
+
const claims = await client.auth.agent.requestChallenge(publicKey);
|
|
21
|
+
const timestamp = Date.now();
|
|
22
|
+
if (claims.action !== 'agent_signin' || claims.accountId !== accountId || claims.actorId !== accountId
|
|
23
|
+
|| normalizeSecp256k1PublicKey(claims.publicKey) !== publicKey || !claims.authMethodId || claims.expiresAt <= timestamp) {
|
|
24
|
+
throw new Error('Agent challenge does not match this account and signer');
|
|
25
|
+
}
|
|
26
|
+
const signature = await signer.signMessage(buildAgentProofMessage(claims, timestamp));
|
|
27
|
+
return client.auth.agent.verify(publicKey, { challenge: claims.challenge, timestamp, signature });
|
|
28
|
+
}
|
package/src/server/auth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
2
|
import type { AuthMiddlewareOptions, OxyAuthRefusal, OxyMiddleware } from './middleware';
|
|
3
|
+
import type { AccountActorChain } from '@oxy.so/contracts';
|
|
3
4
|
import { logger } from '../logger';
|
|
4
5
|
import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
5
6
|
|
|
@@ -43,6 +44,12 @@ export interface OxyAuthRequest extends Request {
|
|
|
43
44
|
sessionId?: string | null;
|
|
44
45
|
serviceApp?: OxyServiceAppContext;
|
|
45
46
|
serviceActingAs?: OxyServiceActingAsContext;
|
|
47
|
+
/**
|
|
48
|
+
* Who acted, and as whom, on a USER session — read off the session row by the
|
|
49
|
+
* Oxy API, never off a header. `null`/absent when the request is not a user
|
|
50
|
+
* session or the API did not report it. Read it with {@link getOxyActor}.
|
|
51
|
+
*/
|
|
52
|
+
oxyActor?: AccountActorChain | null;
|
|
46
53
|
/**
|
|
47
54
|
* Why the presented credential was refused, when one was and the request
|
|
48
55
|
* still reached the handler (the optional path). Read it with
|
|
@@ -95,6 +102,24 @@ export function getOxyUserId(req: Request): string | null {
|
|
|
95
102
|
);
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Who acted, and as whom, on this user session (issue #1520), or `null` when
|
|
107
|
+
* the session authority did not report it.
|
|
108
|
+
*
|
|
109
|
+
* An audit records `actorAccountId` as the actor and `effectiveAccountId`
|
|
110
|
+
* (equal to {@link getOxyUserId}) as the account spoken as. A bot acting with
|
|
111
|
+
* nobody operating it is its own actor; a person acting as an organization or
|
|
112
|
+
* a bot is the actor of a `delegated` chain. Never substitute the effective
|
|
113
|
+
* account's owner for either: when this is `null`, record the actor as unknown.
|
|
114
|
+
*/
|
|
115
|
+
export function getOxyActor(req: Request): AccountActorChain | null {
|
|
116
|
+
const actor = (req as OxyAuthRequest).oxyActor;
|
|
117
|
+
if (!actor) return null;
|
|
118
|
+
// Defence in depth: a chain that does not describe the authenticated
|
|
119
|
+
// subject is not this request's chain.
|
|
120
|
+
return actor.effectiveAccountId === getOxyUserId(req) ? actor : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
export function isOxyAuthenticated(req: Request): req is OxyAuthenticatedRequest {
|
|
99
124
|
return getOxyUserId(req) !== null;
|
|
100
125
|
}
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
capabilityTicketClaimsSchema,
|
|
9
9
|
type CapabilityTicketClaims,
|
|
10
|
-
type GrantLimit,
|
|
11
10
|
type PolicyDecision,
|
|
12
11
|
} from '@oxy.so/contracts';
|
|
13
12
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
@@ -74,47 +73,6 @@ function decodeJsonSegment(segment: string): unknown {
|
|
|
74
73
|
}
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
function valuesAtPath(input: Record<string, unknown>, path: string): unknown[] {
|
|
78
|
-
let current: unknown[] = [input];
|
|
79
|
-
for (const segment of path.split('.')) {
|
|
80
|
-
const next: unknown[] = [];
|
|
81
|
-
for (const value of current) {
|
|
82
|
-
const records = Array.isArray(value) ? value : [value];
|
|
83
|
-
for (const record of records) {
|
|
84
|
-
if (typeof record !== 'object' || record === null || Array.isArray(record)) continue;
|
|
85
|
-
if (Object.prototype.hasOwnProperty.call(record, segment)) {
|
|
86
|
-
next.push((record as Record<string, unknown>)[segment]);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (next.length === 0) return [];
|
|
91
|
-
current = next;
|
|
92
|
-
}
|
|
93
|
-
return current.flatMap((value) => Array.isArray(value) ? value : [value]);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Enforces the signed per-action bounds before a domain handler runs. */
|
|
97
|
-
export function inputSatisfiesCapabilityLimits(
|
|
98
|
-
tool: string,
|
|
99
|
-
input: Record<string, unknown>,
|
|
100
|
-
limits: readonly GrantLimit[],
|
|
101
|
-
): boolean {
|
|
102
|
-
for (const limit of limits) {
|
|
103
|
-
if (limit.tool !== tool) return false;
|
|
104
|
-
const actualValues = valuesAtPath(input, limit.key);
|
|
105
|
-
if (actualValues.length === 0) return false;
|
|
106
|
-
if (typeof limit.value === 'number') {
|
|
107
|
-
const maximum = limit.value;
|
|
108
|
-
if (!actualValues.every((actual) => (
|
|
109
|
-
typeof actual === 'number' && Number.isFinite(actual) && actual <= maximum
|
|
110
|
-
))) return false;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (!actualValues.every((actual) => actual === limit.value)) return false;
|
|
114
|
-
}
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
76
|
export function issueCapabilityTicket(
|
|
119
77
|
claims: UnsignedCapabilityTicketClaims,
|
|
120
78
|
options: CapabilityTicketSigningOptions,
|
|
@@ -256,3 +214,5 @@ export function createCapabilityTicketMiddleware(
|
|
|
256
214
|
}
|
|
257
215
|
};
|
|
258
216
|
}
|
|
217
|
+
|
|
218
|
+
export { inputSatisfiesCapabilityLimits } from '@oxy.so/contracts';
|
package/src/server/index.ts
CHANGED
|
@@ -26,6 +26,12 @@ export {
|
|
|
26
26
|
ServerNotificationsApi,
|
|
27
27
|
ServerReputationApi,
|
|
28
28
|
} from './namespaces';
|
|
29
|
+
export type {
|
|
30
|
+
CapabilityTicketGrant,
|
|
31
|
+
CapabilityTicketIntrospection,
|
|
32
|
+
CreateExecutionAuthorizationInput,
|
|
33
|
+
ServiceCapabilityCatalog,
|
|
34
|
+
} from './namespaces';
|
|
29
35
|
export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware';
|
|
30
36
|
export type {
|
|
31
37
|
AuthMiddlewareOptions,
|
|
@@ -40,6 +46,7 @@ export type {
|
|
|
40
46
|
export {
|
|
41
47
|
createOptionalOxyAuth,
|
|
42
48
|
createOxyAuthMiddleware,
|
|
49
|
+
getOxyActor,
|
|
43
50
|
getOxyAuthRefusal,
|
|
44
51
|
getOxyBillingPrincipal,
|
|
45
52
|
getOxyDelegatedUserId,
|
|
@@ -163,6 +170,8 @@ export type {
|
|
|
163
170
|
|
|
164
171
|
// Constant-time secret comparison.
|
|
165
172
|
export { verifySecret } from './verifySecret';
|
|
173
|
+
export { createLiveCapabilityTicketVerifier } from './liveCapabilityTicket';
|
|
174
|
+
export type { LiveCapabilityTicketVerifierOptions } from './liveCapabilityTicket';
|
|
166
175
|
|
|
167
176
|
// Cross-service user-invalidation signal: oxy-api publishes when identity
|
|
168
177
|
// changes, every consuming backend sweeps its caches instead of waiting out a TTL.
|
|
@@ -200,3 +209,7 @@ export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp';
|
|
|
200
209
|
*/
|
|
201
210
|
export { canAttestWorkloadIdentity, requestWorkloadServiceToken } from './workloadIdentity';
|
|
202
211
|
export type { WorkloadServiceToken, WorkloadServiceTokenOptions } from './workloadIdentity';
|
|
212
|
+
|
|
213
|
+
export { OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS, hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
|
|
214
|
+
export { signInAgentAccount } from './agentAccount';
|
|
215
|
+
export type { AgentAccountSigner } from './agentAccount';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { canonicalCapabilityJson, capabilityTicketClaimsSchema, type CapabilityTicketClaims } from '@oxy.so/contracts';
|
|
2
|
+
import { verifyCapabilityTicket, type CapabilityTicketVerificationOptions } from './capabilityTicket';
|
|
3
|
+
import type { CapabilityTicketIntrospection } from './namespaces';
|
|
4
|
+
|
|
5
|
+
export interface LiveCapabilityTicketVerifierOptions extends CapabilityTicketVerificationOptions {
|
|
6
|
+
issuer: string;
|
|
7
|
+
introspect: (ticket: string, options: { signal: AbortSignal }) => Promise<CapabilityTicketIntrospection>;
|
|
8
|
+
/** Network bound only; does not establish an authority freshness policy. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Local signature + live receiving-service introspection; no cache or retry. */
|
|
13
|
+
export function createLiveCapabilityTicketVerifier(options: LiveCapabilityTicketVerifierOptions) {
|
|
14
|
+
if (typeof options.issuer !== 'string' || options.issuer.trim() === '') throw new Error('A trusted Capability issuer is required');
|
|
15
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
16
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) {
|
|
17
|
+
throw new Error('Capability introspection timeout must be between 1 and 60000ms');
|
|
18
|
+
}
|
|
19
|
+
return async (ticket: string, input: { signal?: AbortSignal } = {}): Promise<CapabilityTicketClaims> => {
|
|
20
|
+
input.signal?.throwIfAborted();
|
|
21
|
+
const signed = verifyCapabilityTicket(ticket, options);
|
|
22
|
+
if (!signed.catalog) throw new Error('Internal MCP requires a signed catalogue binding');
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const abort = () => controller.abort(input.signal?.reason);
|
|
25
|
+
input.signal?.addEventListener('abort', abort, { once: true });
|
|
26
|
+
const timer = setTimeout(() => controller.abort(new Error('Capability introspection timed out')), timeoutMs);
|
|
27
|
+
let rejectAbort: (() => void) | undefined;
|
|
28
|
+
try {
|
|
29
|
+
const interrupted = new Promise<never>((_resolve, reject) => {
|
|
30
|
+
rejectAbort = () => reject(controller.signal.reason ?? new Error('Capability introspection aborted'));
|
|
31
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
32
|
+
if (controller.signal.aborted) rejectAbort();
|
|
33
|
+
});
|
|
34
|
+
const result = await Promise.race([options.introspect(ticket, { signal: controller.signal }), interrupted]);
|
|
35
|
+
controller.signal.throwIfAborted();
|
|
36
|
+
if (result.active !== true) throw new Error('Capability ticket is inactive');
|
|
37
|
+
const live = capabilityTicketClaimsSchema.parse(result.claims);
|
|
38
|
+
if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed)) throw new Error('Capability introspection claims mismatch');
|
|
39
|
+
// An authority response delayed past expiry cannot extend the signed grant.
|
|
40
|
+
return verifyCapabilityTicket(ticket, options);
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
input.signal?.removeEventListener('abort', abort);
|
|
44
|
+
if (rejectAbort) controller.signal.removeEventListener('abort', rejectAbort);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/server/middleware.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime';
|
|
1
2
|
/**
|
|
2
3
|
* The Express and Socket.IO middleware `OxyServer` mounts
|
|
3
4
|
* (`server.middleware.auth()`, `.socket()`, `.service()`, `.requireScope()`),
|
|
@@ -6,6 +7,7 @@
|
|
|
6
7
|
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
7
8
|
*/
|
|
8
9
|
import type { OxyRequestUser } from './auth';
|
|
10
|
+
import { accountActorChainSchema, type AccountActorChain } from '@oxy.so/contracts';
|
|
9
11
|
import { jwtDecode } from 'jwt-decode';
|
|
10
12
|
import type { JsonWebKey } from 'node:crypto';
|
|
11
13
|
import type { ApiError, User } from '../models/interfaces';
|
|
@@ -54,6 +56,8 @@ interface JwtPayload {
|
|
|
54
56
|
* authoritative store stays server-side.
|
|
55
57
|
*/
|
|
56
58
|
export interface ServiceActingAsVerification {
|
|
59
|
+
/** Decimal durable generation, never lossy JS number. */
|
|
60
|
+
epoch?: string;
|
|
57
61
|
authorized: boolean;
|
|
58
62
|
scopes: string[];
|
|
59
63
|
}
|
|
@@ -148,11 +152,10 @@ export interface ServiceApp {
|
|
|
148
152
|
appName: string;
|
|
149
153
|
scopes: string[];
|
|
150
154
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* receiving service to decide. A token without the claim reads as `external`.
|
|
155
|
+
* Classification only. Both tiers obey scope ceilings and live offline
|
|
156
|
+
* delegation; neither a brand nor a header proves a user's authority.
|
|
157
|
+
* Resource authorization remains with the receiving service. A missing
|
|
158
|
+
* claim reads as external.
|
|
156
159
|
*/
|
|
157
160
|
tier: 'internal' | 'external';
|
|
158
161
|
/** The credentialId of the specific service credential that minted this token. */
|
|
@@ -299,8 +302,10 @@ export interface OxyMiddlewareHost {
|
|
|
299
302
|
validateSession(
|
|
300
303
|
sessionId: string,
|
|
301
304
|
options?: { deviceFingerprint?: string; useHeaderValidation?: boolean },
|
|
302
|
-
): Promise<{ valid: boolean; user?: User } | null>;
|
|
303
|
-
verifyActingAs(appId: string, userId: string
|
|
305
|
+
): Promise<{ valid: boolean; user?: User; actor?: unknown } | null>;
|
|
306
|
+
verifyActingAs(appId: string, userId: string, options?: {
|
|
307
|
+
cache?: boolean; credentialId?: string; ownerAccountId?: string; environment?: string;
|
|
308
|
+
}): Promise<ServiceActingAsVerification | null>;
|
|
304
309
|
}
|
|
305
310
|
|
|
306
311
|
/** Build the middleware set for one server. See `OxyServer.middleware`. */
|
|
@@ -680,21 +685,18 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
|
|
|
680
685
|
return res.status(401).json(error);
|
|
681
686
|
}
|
|
682
687
|
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (oxyUserId && tier === 'internal') {
|
|
688
|
-
req.userId = oxyUserId;
|
|
689
|
-
req.user = { id: oxyUserId };
|
|
690
|
-
req.serviceActingAs = { userId: oxyUserId, scopes: [] };
|
|
691
|
-
} else if (oxyUserId) {
|
|
692
|
-
// C3: an EXTERNAL service may only act as a user when an explicit
|
|
688
|
+
// A service credential plus a user header is never a present user
|
|
689
|
+
// session, even for an internal tier. Offline delegation is explicit.
|
|
690
|
+
if (oxyUserId) {
|
|
691
|
+
// C3: every service may only act as a user when an explicit
|
|
693
692
|
// ServiceActingAs grant exists for that (appId, userId) pair.
|
|
694
693
|
// Without the grant we MUST refuse — silently attaching
|
|
695
694
|
// `req.userId = oxyUserId` would let any service impersonate
|
|
696
695
|
// any user simply by setting the header.
|
|
697
|
-
const grant = await host.verifyActingAs(appId, oxyUserId
|
|
696
|
+
const grant = await host.verifyActingAs(appId, oxyUserId, {
|
|
697
|
+
cache: false, credentialId: decoded.credentialId as string,
|
|
698
|
+
ownerAccountId: decoded.ownerAccountId as string, environment: decoded.environment as string,
|
|
699
|
+
});
|
|
698
700
|
if (!grant || !grant.authorized) {
|
|
699
701
|
logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
|
|
700
702
|
component: 'auth',
|
|
@@ -925,7 +927,44 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
|
|
|
925
927
|
return res.status(401).json(error);
|
|
926
928
|
}
|
|
927
929
|
|
|
930
|
+
// Who acted, and as whom — from the session authority's answer
|
|
931
|
+
// (#1520), never from a header or this undecoded token. An API that
|
|
932
|
+
// predates the field answers without it, and that is `null` here:
|
|
933
|
+
// "not reported", never a guess that the subject acted for itself.
|
|
934
|
+
// A chain naming a DIFFERENT effective account than the session
|
|
935
|
+
// resolved to has no benign cause and is refused like a user
|
|
936
|
+
// mismatch.
|
|
937
|
+
const actor = readActorChain(validationResult.actor);
|
|
938
|
+
if (actor === 'invalid' || (actor && actor.effectiveAccountId !== validatedUserId)) {
|
|
939
|
+
logger.warn('[oxy.auth] Session rejected — actor chain does not describe the session', {
|
|
940
|
+
component: 'auth',
|
|
941
|
+
method: 'auth',
|
|
942
|
+
validatedUserId,
|
|
943
|
+
});
|
|
944
|
+
recordRefusal(req, {
|
|
945
|
+
code: 'SESSION_ACTOR_MISMATCH',
|
|
946
|
+
stage: 'session',
|
|
947
|
+
reason: 'Session actor chain does not describe the validated session',
|
|
948
|
+
status: 401,
|
|
949
|
+
});
|
|
950
|
+
if (optional) {
|
|
951
|
+
req.userId = null;
|
|
952
|
+
req.user = null;
|
|
953
|
+
return next();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const error = {
|
|
957
|
+
error: 'SESSION_ACTOR_MISMATCH',
|
|
958
|
+
message: 'Session actor does not match the session',
|
|
959
|
+
code: 'SESSION_ACTOR_MISMATCH',
|
|
960
|
+
status: 401
|
|
961
|
+
};
|
|
962
|
+
if (onError) return onError(error);
|
|
963
|
+
return res.status(401).json(error);
|
|
964
|
+
}
|
|
965
|
+
|
|
928
966
|
req.userId = validatedUserId;
|
|
967
|
+
req.oxyActor = actor;
|
|
929
968
|
req.accessToken = token;
|
|
930
969
|
req.sessionId = sessionId;
|
|
931
970
|
// Session validation already returned the full user, so `loadUser`
|
|
@@ -1216,13 +1255,6 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
|
|
|
1216
1255
|
return;
|
|
1217
1256
|
}
|
|
1218
1257
|
|
|
1219
|
-
// Oxy's own applications are trusted app to app; scopes are the
|
|
1220
|
-
// external lane.
|
|
1221
|
-
if (req.serviceApp.tier === 'internal') {
|
|
1222
|
-
next();
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
1258
|
const appHasScope = appScopes.includes(scope);
|
|
1227
1259
|
const delegationHasScope = delegatedScopes.includes(scope);
|
|
1228
1260
|
const hasRequiredScope = req.serviceActingAs
|
|
@@ -1468,6 +1500,9 @@ function verifyServiceTokenClaims(
|
|
|
1468
1500
|
decoded: JwtPayload,
|
|
1469
1501
|
expected: { audience: string; issuer: string },
|
|
1470
1502
|
): void {
|
|
1503
|
+
if (!hasBoundedServiceTokenLifetime(decoded)) {
|
|
1504
|
+
throw new ServiceTokenClaimError('Service token lifetime must be positive and at most 300 seconds');
|
|
1505
|
+
}
|
|
1471
1506
|
if (decoded.type !== 'service') {
|
|
1472
1507
|
throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
|
|
1473
1508
|
}
|
|
@@ -1484,6 +1519,17 @@ function verifyServiceTokenClaims(
|
|
|
1484
1519
|
}
|
|
1485
1520
|
}
|
|
1486
1521
|
|
|
1522
|
+
/**
|
|
1523
|
+
* Parse the session authority's actor chain. `null` when the API did not send
|
|
1524
|
+
* one; `'invalid'` when it sent something that is not a chain, which the caller
|
|
1525
|
+
* refuses rather than ignores.
|
|
1526
|
+
*/
|
|
1527
|
+
function readActorChain(raw: unknown): AccountActorChain | null | 'invalid' {
|
|
1528
|
+
if (raw === undefined || raw === null) return null;
|
|
1529
|
+
const parsed = accountActorChainSchema.safeParse(raw);
|
|
1530
|
+
return parsed.success ? parsed.data : 'invalid';
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1487
1533
|
// ---------------------------------------------------------------------------
|
|
1488
1534
|
// Local request/response/socket typing
|
|
1489
1535
|
//
|
|
@@ -1506,6 +1552,7 @@ interface AuthReq {
|
|
|
1506
1552
|
sessionId?: string | null;
|
|
1507
1553
|
serviceApp?: ServiceApp;
|
|
1508
1554
|
serviceActingAs?: { userId: string; scopes: string[] };
|
|
1555
|
+
oxyActor?: AccountActorChain | null;
|
|
1509
1556
|
oxyAuthRefusal?: OxyAuthRefusal;
|
|
1510
1557
|
}
|
|
1511
1558
|
|