@oxy.so/core 4.1.0 → 4.3.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 +34 -15
- 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 +32 -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 +18 -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 +35 -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/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
|
|
package/src/server/namespaces.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `server.assets.metadataByIds(...)` sits beside every client method of
|
|
5
5
|
* `oxy.assets`.
|
|
6
6
|
*/
|
|
7
|
-
import type
|
|
7
|
+
import { capabilityTicketRequestSchema, foregroundExecutionAuthorizationInputSchema, type ForegroundExecutionAuthorizationInput, type ActorRef, type AppCapabilityCatalog, type AutonomyLevel, type CapabilityTicketClaims, type CapabilityTicketRequest, type GrantLimit, type PolicyDecision, type ResourceRef, type CreateOxyNotificationRequest, type AwardReputationInput, type ReputationTransaction, type ServiceLinkedAccountListResponse } from '@oxy.so/contracts';
|
|
8
|
+
import type { RequestOptions } from '../HttpService';
|
|
8
9
|
import type { OxyContext, ServiceLane } from '../client/context';
|
|
9
10
|
import type {
|
|
10
11
|
Notification,
|
|
@@ -15,7 +16,7 @@ import type {
|
|
|
15
16
|
import { AssetsApi } from '../api/assets';
|
|
16
17
|
import { NotificationsApi } from '../api/notifications';
|
|
17
18
|
import { LinkedAccountsApi } from '../api/linkedAccounts';
|
|
18
|
-
import { AgencyApi, type RequesterAssertionGrant, type RequesterAssertionIntrospection } from '../api/agency';
|
|
19
|
+
import { AgencyApi, type CapabilityExecutionAuthorization, type RequesterAssertionGrant, type RequesterAssertionIntrospection } from '../api/agency';
|
|
19
20
|
import { ReputationApi } from '../api/reputation';
|
|
20
21
|
import { ServiceAssetMetadataError, ServiceLinkedDownloadUrlError } from '../OxyServices.errors';
|
|
21
22
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
@@ -218,7 +219,103 @@ export class ServerLinkedAccountsApi extends LinkedAccountsApi {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/** Projection of the existing service-authenticated registry response. */
|
|
223
|
+
export interface ServiceCapabilityCatalog {
|
|
224
|
+
id: string;
|
|
225
|
+
appSlug: string;
|
|
226
|
+
version: string;
|
|
227
|
+
digest: string;
|
|
228
|
+
audience: string;
|
|
229
|
+
catalog: AppCapabilityCatalog;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface CapabilityTicketGrant {
|
|
233
|
+
decision: PolicyDecision;
|
|
234
|
+
ticket?: string;
|
|
235
|
+
claims?: CapabilityTicketClaims;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface CapabilityTicketIntrospection {
|
|
239
|
+
active: boolean;
|
|
240
|
+
claims?: CapabilityTicketClaims;
|
|
241
|
+
decision?: PolicyDecision;
|
|
242
|
+
error?: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface ExecutionAuthorizationTerms {
|
|
246
|
+
ownerAccountId: string;
|
|
247
|
+
coordinatorApplicationId: string;
|
|
248
|
+
coordinatorCredentialId: string;
|
|
249
|
+
actor: ActorRef;
|
|
250
|
+
resource: ResourceRef;
|
|
251
|
+
tool: string;
|
|
252
|
+
limits?: GrantLimit[];
|
|
253
|
+
expiresAt: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type CreateExecutionAuthorizationInput = ExecutionAuthorizationTerms & (
|
|
257
|
+
| { kind: 'direct_request'; runId: string; stepId?: string; automationId?: never; maximumAutonomy: Exclude<AutonomyLevel, 'autonomous'> }
|
|
258
|
+
| { kind: 'automation'; automationId: string; runId?: never; stepId?: never; maximumAutonomy: AutonomyLevel }
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
function agencyAuthorityOptions(signal?: AbortSignal): RequestOptions {
|
|
262
|
+
signal?.throwIfAborted();
|
|
263
|
+
return { cache: false, deduplicate: false, retry: false, skipAuth: true, timeout: 5000, ...(signal ? { signal } : {}) };
|
|
264
|
+
}
|
|
265
|
+
|
|
221
266
|
export class ServerAgencyApi extends AgencyApi {
|
|
267
|
+
/** Live registry discovery with this service's existing capability scopes. */
|
|
268
|
+
async serviceCatalogs(input: { appId?: string; signal?: AbortSignal } = {}): Promise<ServiceCapabilityCatalog[]> {
|
|
269
|
+
const path = `/capabilities/catalogs${input.appId === undefined ? '' : `?appId=${encodeURIComponent(input.appId)}`}`;
|
|
270
|
+
const result = await lane(this.ctx).request<{ registrations: ServiceCapabilityCatalog[] }>('GET', path, undefined, agencyAuthorityOptions(input.signal));
|
|
271
|
+
return result.registrations;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Existing Oxy execution authority is required; this call creates none. */
|
|
275
|
+
async issueCapabilityTicket(input: CapabilityTicketRequest, options: { signal?: AbortSignal } = {}): Promise<CapabilityTicketGrant> {
|
|
276
|
+
return lane(this.ctx).request<CapabilityTicketGrant>('POST', '/capabilities/tickets', capabilityTicketRequestSchema.parse(input), agencyAuthorityOptions(options.signal));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Receiving app authenticates live introspection using its own service credential. */
|
|
280
|
+
async introspectCapabilityTicket(ticket: string, options: { signal?: AbortSignal } = {}): Promise<CapabilityTicketIntrospection> {
|
|
281
|
+
if (ticket.trim() === '') throw new Error('A capability ticket is required');
|
|
282
|
+
return lane(this.ctx).request<CapabilityTicketIntrospection>('POST', '/capabilities/tickets/introspect', { ticket }, agencyAuthorityOptions(options.signal));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Authenticates the existing user-authority route with the requester's bearer.
|
|
287
|
+
* The service bearer and attribution headers cannot substitute for that user.
|
|
288
|
+
* The token goes only to this client's configured Oxy authority; never retry.
|
|
289
|
+
*/
|
|
290
|
+
async createExecutionAuthorization(input: CreateExecutionAuthorizationInput, options: { requesterToken: string; signal?: AbortSignal }): Promise<CapabilityExecutionAuthorization> {
|
|
291
|
+
if (options.requesterToken === '' || /\s/.test(options.requesterToken)) throw new Error('A requester bearer is required');
|
|
292
|
+
const result = await this.ctx.request<{ authorization: CapabilityExecutionAuthorization }>('POST', '/capabilities/execution-authorizations', input, {
|
|
293
|
+
...agencyAuthorityOptions(options.signal),
|
|
294
|
+
headers: { Authorization: `Bearer ${options.requesterToken}` },
|
|
295
|
+
});
|
|
296
|
+
return result.authorization;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Revoke a direct approval on its canonical requester-authenticated lane. */
|
|
300
|
+
async revokeExecutionAuthorization(authorizationId: string, options: { requesterToken: string; signal?: AbortSignal }): Promise<void> {
|
|
301
|
+
options.signal?.throwIfAborted();
|
|
302
|
+
if (!authorizationId || authorizationId.length > 255 || /\s/.test(authorizationId)) throw new Error('An execution authorization id is required');
|
|
303
|
+
if (!options.requesterToken || options.requesterToken.length > 16_384 || /\s/.test(options.requesterToken)) throw new Error('A requester bearer is required');
|
|
304
|
+
await this.ctx.request<void>('DELETE', `/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`,
|
|
305
|
+
undefined, { ...agencyAuthorityOptions(options.signal), headers: { Authorization: `Bearer ${options.requesterToken}` } });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Independent service proof plus present requester bearer, sent only to Oxy. */
|
|
309
|
+
async createForegroundExecutionAuthorization(input: ForegroundExecutionAuthorizationInput, options: { requesterToken: string; signal?: AbortSignal }): Promise<CapabilityExecutionAuthorization> {
|
|
310
|
+
if (options.requesterToken === '' || options.requesterToken.length > 16_384 || /\s/.test(options.requesterToken)) throw new Error('A requester bearer is required');
|
|
311
|
+
const result = await lane(this.ctx).request<{ authorization: CapabilityExecutionAuthorization }>(
|
|
312
|
+
'POST', '/capabilities/foreground-execution-authorizations',
|
|
313
|
+
{ ...foregroundExecutionAuthorizationInputSchema.parse(input), subjectToken: options.requesterToken },
|
|
314
|
+
agencyAuthorityOptions(options.signal),
|
|
315
|
+
);
|
|
316
|
+
return result.authorization;
|
|
317
|
+
}
|
|
318
|
+
|
|
222
319
|
/**
|
|
223
320
|
* Product backend → Oxy: trade the signed-in requester's access token for a
|
|
224
321
|
* one-use assertion naming `agentId` (ADR 0025), authenticated with this
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Approved I03 ceiling shared by issuance and both JWT verifiers. */
|
|
2
|
+
export const OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = 300;
|
|
3
|
+
|
|
4
|
+
/** Call only after signature verification; cache checks alone confer no authority. */
|
|
5
|
+
export function hasBoundedServiceTokenLifetime(
|
|
6
|
+
claims: { iat?: unknown; exp?: unknown },
|
|
7
|
+
nowSeconds = Math.floor(Date.now() / 1000),
|
|
8
|
+
): boolean {
|
|
9
|
+
return typeof claims.iat === 'number' && Number.isSafeInteger(claims.iat)
|
|
10
|
+
&& typeof claims.exp === 'number' && Number.isSafeInteger(claims.exp)
|
|
11
|
+
&& claims.iat <= nowSeconds && claims.exp > claims.iat
|
|
12
|
+
&& claims.exp - claims.iat <= OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS;
|
|
13
|
+
}
|