@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.
Files changed (95) hide show
  1. package/dist/cjs/HttpService.js +142 -33
  2. package/dist/cjs/api/accounts.js +49 -10
  3. package/dist/cjs/api/apps.js +2 -2
  4. package/dist/cjs/api/auth.js +24 -3
  5. package/dist/cjs/api/billing.js +35 -0
  6. package/dist/cjs/boot/sessionColdBoot.js +28 -57
  7. package/dist/cjs/inference/OxyInferenceClient.js +53 -20
  8. package/dist/cjs/server/OxyServer.js +107 -51
  9. package/dist/cjs/server/agentAccount.js +19 -0
  10. package/dist/cjs/server/auth.js +19 -0
  11. package/dist/cjs/server/capabilityTicket.js +3 -41
  12. package/dist/cjs/server/index.js +10 -2
  13. package/dist/cjs/server/liveCapabilityTicket.js +48 -0
  14. package/dist/cjs/server/middleware.js +60 -18
  15. package/dist/cjs/server/namespaces.js +57 -0
  16. package/dist/cjs/server/serviceTokenLifetime.js +13 -0
  17. package/dist/cjs/session/SessionClient.js +90 -12
  18. package/dist/cjs/session/authStateStore.js +130 -39
  19. package/dist/cjs/session/refresh.js +152 -30
  20. package/dist/cjs/session/sharedDeviceCredential.js +17 -1
  21. package/dist/cjs/utils/officialOrigins.js +7 -18
  22. package/dist/esm/HttpService.js +143 -34
  23. package/dist/esm/api/accounts.js +38 -0
  24. package/dist/esm/api/apps.js +2 -2
  25. package/dist/esm/api/auth.js +23 -3
  26. package/dist/esm/api/billing.js +35 -0
  27. package/dist/esm/boot/sessionColdBoot.js +29 -58
  28. package/dist/esm/inference/OxyInferenceClient.js +46 -13
  29. package/dist/esm/server/OxyServer.js +107 -51
  30. package/dist/esm/server/agentAccount.js +16 -0
  31. package/dist/esm/server/auth.js +18 -0
  32. package/dist/esm/server/capabilityTicket.js +1 -39
  33. package/dist/esm/server/index.js +4 -1
  34. package/dist/esm/server/liveCapabilityTicket.js +45 -0
  35. package/dist/esm/server/middleware.js +60 -18
  36. package/dist/esm/server/namespaces.js +57 -0
  37. package/dist/esm/server/serviceTokenLifetime.js +9 -0
  38. package/dist/esm/session/SessionClient.js +90 -12
  39. package/dist/esm/session/authStateStore.js +128 -38
  40. package/dist/esm/session/refresh.js +151 -30
  41. package/dist/esm/session/sharedDeviceCredential.js +17 -1
  42. package/dist/esm/utils/officialOrigins.js +2 -13
  43. package/dist/types/HttpService.d.ts +26 -12
  44. package/dist/types/api/accounts.d.ts +38 -0
  45. package/dist/types/api/agency.d.ts +3 -1
  46. package/dist/types/api/apps.d.ts +3 -1
  47. package/dist/types/api/auth.d.ts +12 -1
  48. package/dist/types/api/billing.d.ts +12 -0
  49. package/dist/types/api/session.d.ts +3 -1
  50. package/dist/types/boot/sessionColdBoot.d.ts +1 -1
  51. package/dist/types/index.d.ts +1 -1
  52. package/dist/types/inference/OxyInferenceClient.d.ts +12 -0
  53. package/dist/types/inference/index.d.ts +1 -1
  54. package/dist/types/server/OxyServer.d.ts +16 -9
  55. package/dist/types/server/agentAccount.d.ts +14 -0
  56. package/dist/types/server/auth.d.ts +18 -0
  57. package/dist/types/server/capabilityTicket.d.ts +2 -4
  58. package/dist/types/server/index.d.ts +7 -1
  59. package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
  60. package/dist/types/server/middleware.d.ts +15 -6
  61. package/dist/types/server/namespaces.d.ts +78 -2
  62. package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
  63. package/dist/types/session/SessionClient.d.ts +8 -0
  64. package/dist/types/session/authStateStore.d.ts +15 -0
  65. package/dist/types/session/index.d.ts +1 -1
  66. package/dist/types/session/refresh.d.ts +32 -7
  67. package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
  68. package/dist/types/utils/officialOrigins.d.ts +1 -5
  69. package/package.json +6 -6
  70. package/src/HttpService.ts +138 -50
  71. package/src/api/accounts.ts +22 -0
  72. package/src/api/agency.ts +3 -1
  73. package/src/api/apps.ts +2 -2
  74. package/src/api/auth.ts +31 -2
  75. package/src/api/billing.ts +71 -9
  76. package/src/api/session.ts +3 -1
  77. package/src/boot/sessionColdBoot.ts +27 -72
  78. package/src/index.ts +1 -1
  79. package/src/inference/OxyInferenceClient.ts +50 -5
  80. package/src/inference/index.ts +1 -0
  81. package/src/server/OxyServer.ts +114 -53
  82. package/src/server/agentAccount.ts +28 -0
  83. package/src/server/auth.ts +25 -0
  84. package/src/server/capabilityTicket.ts +2 -42
  85. package/src/server/index.ts +13 -0
  86. package/src/server/liveCapabilityTicket.ts +47 -0
  87. package/src/server/middleware.ts +72 -25
  88. package/src/server/namespaces.ts +99 -2
  89. package/src/server/serviceTokenLifetime.ts +13 -0
  90. package/src/session/SessionClient.ts +85 -12
  91. package/src/session/authStateStore.ts +130 -46
  92. package/src/session/index.ts +1 -0
  93. package/src/session/refresh.ts +144 -28
  94. package/src/session/sharedDeviceCredential.ts +14 -2
  95. package/src/utils/officialOrigins.ts +2 -13
@@ -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
- /** A positive grant is reused for 5 minutes — the revocation latency window. */
131
- const ACTING_AS_GRANT_TTL_MS = 5 * 60 * 1000;
132
- /** A negative answer (or a failed lookup) is reused for 1 minute. */
133
- const ACTING_AS_DENIAL_TTL_MS = 60 * 1000;
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
- * Whether service app `appId` holds an active delegation grant to act for
348
- * `userId`; the grant's scopes, or `null`. Used by `middleware.auth()` for
349
- * `X-Oxy-User-Id`.
350
- *
351
- * Answers are cached per `appId:userId` (a grant for 5 minutes — the
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(appId: string, userId: string): Promise<ServiceActingAsVerification | null> {
357
- const cacheKey = `${appId}:${userId}`;
358
- const cached = this.actingAs.get(cacheKey);
359
- if (cached && cached.expiresAt > Date.now()) {
360
- // Refresh recency for the LRU bound.
361
- this.actingAs.delete(cacheKey);
362
- this.actingAs.set(cacheKey, cached);
363
- return cached.result;
364
- }
365
- const inflight = this.actingAsPending.get(cacheKey);
366
- if (inflight) return inflight;
367
-
368
- const lookup = this.lookupActingAs(appId, userId).finally(() => {
369
- this.actingAsPending.delete(cacheKey);
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(appId: string, userId: string): Promise<ServiceActingAsVerification | null> {
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
- '/internal/service-acting-as/verify',
501
- { appId, userId },
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
- const verified: ServiceActingAsVerification | null = result?.authorized
505
- ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
506
- : null;
507
- this.rememberActingAs(`${appId}:${userId}`, verified, ACTING_AS_GRANT_TTL_MS);
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
- logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
511
- component: 'auth',
512
- method: 'verifyActingAs',
513
- appId,
514
- userId,
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 rememberActingAs(cacheKey: string, result: ServiceActingAsVerification | null, ttlMs: number): void {
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: Date.now() + ttlMs });
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
+ }
@@ -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';
@@ -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
+ }
@@ -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
- * `internal` when the caller is one of Oxy's own applications: app to app it
152
- * is trusted outright — {@link requireScope} passes and it may act for a user
153
- * without a delegation grant. `external` keeps every scope and grant rule.
154
- * What the USER may do (their accounts, plan, limits) is still for the
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): Promise<ServiceActingAsVerification | null>;
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
- // One of Oxy's own applications acts for a user without a grant:
684
- // inside the ecosystem that is trust, not consent. It still acts
685
- // with the USER's authority only — what that user may do is for the
686
- // receiving service to check.
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