@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/dist/esm/server/auth.js
CHANGED
|
@@ -25,6 +25,24 @@ export function getOxyUserId(req) {
|
|
|
25
25
|
normalizeId(authReq.user?.id) ??
|
|
26
26
|
normalizeId(authReq.user?._id));
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Who acted, and as whom, on this user session (issue #1520), or `null` when
|
|
30
|
+
* the session authority did not report it.
|
|
31
|
+
*
|
|
32
|
+
* An audit records `actorAccountId` as the actor and `effectiveAccountId`
|
|
33
|
+
* (equal to {@link getOxyUserId}) as the account spoken as. A bot acting with
|
|
34
|
+
* nobody operating it is its own actor; a person acting as an organization or
|
|
35
|
+
* a bot is the actor of a `delegated` chain. Never substitute the effective
|
|
36
|
+
* account's owner for either: when this is `null`, record the actor as unknown.
|
|
37
|
+
*/
|
|
38
|
+
export function getOxyActor(req) {
|
|
39
|
+
const actor = req.oxyActor;
|
|
40
|
+
if (!actor)
|
|
41
|
+
return null;
|
|
42
|
+
// Defence in depth: a chain that does not describe the authenticated
|
|
43
|
+
// subject is not this request's chain.
|
|
44
|
+
return actor.effectiveAccountId === getOxyUserId(req) ? actor : null;
|
|
45
|
+
}
|
|
28
46
|
export function isOxyAuthenticated(req) {
|
|
29
47
|
return getOxyUserId(req) !== null;
|
|
30
48
|
}
|
|
@@ -21,45 +21,6 @@ function decodeJsonSegment(segment) {
|
|
|
21
21
|
throw new CapabilityTicketError('malformed', 'Capability ticket is not valid base64url JSON');
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
function valuesAtPath(input, path) {
|
|
25
|
-
let current = [input];
|
|
26
|
-
for (const segment of path.split('.')) {
|
|
27
|
-
const next = [];
|
|
28
|
-
for (const value of current) {
|
|
29
|
-
const records = Array.isArray(value) ? value : [value];
|
|
30
|
-
for (const record of records) {
|
|
31
|
-
if (typeof record !== 'object' || record === null || Array.isArray(record))
|
|
32
|
-
continue;
|
|
33
|
-
if (Object.prototype.hasOwnProperty.call(record, segment)) {
|
|
34
|
-
next.push(record[segment]);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (next.length === 0)
|
|
39
|
-
return [];
|
|
40
|
-
current = next;
|
|
41
|
-
}
|
|
42
|
-
return current.flatMap((value) => Array.isArray(value) ? value : [value]);
|
|
43
|
-
}
|
|
44
|
-
/** Enforces the signed per-action bounds before a domain handler runs. */
|
|
45
|
-
export function inputSatisfiesCapabilityLimits(tool, input, limits) {
|
|
46
|
-
for (const limit of limits) {
|
|
47
|
-
if (limit.tool !== tool)
|
|
48
|
-
return false;
|
|
49
|
-
const actualValues = valuesAtPath(input, limit.key);
|
|
50
|
-
if (actualValues.length === 0)
|
|
51
|
-
return false;
|
|
52
|
-
if (typeof limit.value === 'number') {
|
|
53
|
-
const maximum = limit.value;
|
|
54
|
-
if (!actualValues.every((actual) => (typeof actual === 'number' && Number.isFinite(actual) && actual <= maximum)))
|
|
55
|
-
return false;
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (!actualValues.every((actual) => actual === limit.value))
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
24
|
export function issueCapabilityTicket(claims, options) {
|
|
64
25
|
const ttlSeconds = options.ttlSeconds ?? 60;
|
|
65
26
|
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > DEFAULT_MAX_TTL_SECONDS) {
|
|
@@ -181,3 +142,4 @@ export function createCapabilityTicketMiddleware(options) {
|
|
|
181
142
|
}
|
|
182
143
|
};
|
|
183
144
|
}
|
|
145
|
+
export { inputSatisfiesCapabilityLimits } from '@oxy.so/contracts';
|
package/dist/esm/server/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS } from './OxyServer.js';
|
|
20
20
|
export { ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
|
|
21
21
|
export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware.js';
|
|
22
|
-
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
|
|
22
|
+
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyActor, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
|
|
23
23
|
export { createOxyRateLimit } from './rateLimit.js';
|
|
24
24
|
// SSRF-safe upstream fetch + URL validation (Node-only).
|
|
25
25
|
export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch.js';
|
|
@@ -40,6 +40,7 @@ export { CapabilityTicketError, createCapabilityTicketMiddleware, inputSatisfies
|
|
|
40
40
|
export { createOxyJwksKeyResolver, createOxyRequesterAssertionAuth, OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER, OXY_REQUESTER_ASSERTION_HEADER, OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS, OXY_REQUESTER_ASSERTION_TYPE, OxyRequesterAssertionError, oxyRequesterAssertionClaimsSchema, readOxyRequesterAssertionKeyId, signOxyRequesterAssertion, verifyOxyRequesterAssertion, } from './requesterAssertion.js';
|
|
41
41
|
// Constant-time secret comparison.
|
|
42
42
|
export { verifySecret } from './verifySecret.js';
|
|
43
|
+
export { createLiveCapabilityTicketVerifier } from './liveCapabilityTicket.js';
|
|
43
44
|
// Cross-service user-invalidation signal: oxy-api publishes when identity
|
|
44
45
|
// changes, every consuming backend sweeps its caches instead of waiting out a TTL.
|
|
45
46
|
export { createOxyUserInvalidationHandler, publishOxyUserInvalidation, } from './userInvalidation.js';
|
|
@@ -62,3 +63,5 @@ export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp.j
|
|
|
62
63
|
* container credentials endpoint.
|
|
63
64
|
*/
|
|
64
65
|
export { canAttestWorkloadIdentity, requestWorkloadServiceToken } from './workloadIdentity.js';
|
|
66
|
+
export { OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS, hasBoundedServiceTokenLifetime } from './serviceTokenLifetime.js';
|
|
67
|
+
export { signInAgentAccount } from './agentAccount.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { canonicalCapabilityJson, capabilityTicketClaimsSchema } from '@oxy.so/contracts';
|
|
2
|
+
import { verifyCapabilityTicket } from './capabilityTicket.js';
|
|
3
|
+
/** Local signature + live receiving-service introspection; no cache or retry. */
|
|
4
|
+
export function createLiveCapabilityTicketVerifier(options) {
|
|
5
|
+
if (typeof options.issuer !== 'string' || options.issuer.trim() === '')
|
|
6
|
+
throw new Error('A trusted Capability issuer is required');
|
|
7
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
8
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) {
|
|
9
|
+
throw new Error('Capability introspection timeout must be between 1 and 60000ms');
|
|
10
|
+
}
|
|
11
|
+
return async (ticket, input = {}) => {
|
|
12
|
+
input.signal?.throwIfAborted();
|
|
13
|
+
const signed = verifyCapabilityTicket(ticket, options);
|
|
14
|
+
if (!signed.catalog)
|
|
15
|
+
throw new Error('Internal MCP requires a signed catalogue binding');
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const abort = () => controller.abort(input.signal?.reason);
|
|
18
|
+
input.signal?.addEventListener('abort', abort, { once: true });
|
|
19
|
+
const timer = setTimeout(() => controller.abort(new Error('Capability introspection timed out')), timeoutMs);
|
|
20
|
+
let rejectAbort;
|
|
21
|
+
try {
|
|
22
|
+
const interrupted = new Promise((_resolve, reject) => {
|
|
23
|
+
rejectAbort = () => reject(controller.signal.reason ?? new Error('Capability introspection aborted'));
|
|
24
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
25
|
+
if (controller.signal.aborted)
|
|
26
|
+
rejectAbort();
|
|
27
|
+
});
|
|
28
|
+
const result = await Promise.race([options.introspect(ticket, { signal: controller.signal }), interrupted]);
|
|
29
|
+
controller.signal.throwIfAborted();
|
|
30
|
+
if (result.active !== true)
|
|
31
|
+
throw new Error('Capability ticket is inactive');
|
|
32
|
+
const live = capabilityTicketClaimsSchema.parse(result.claims);
|
|
33
|
+
if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed))
|
|
34
|
+
throw new Error('Capability introspection claims mismatch');
|
|
35
|
+
// An authority response delayed past expiry cannot extend the signed grant.
|
|
36
|
+
return verifyCapabilityTicket(ticket, options);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
input.signal?.removeEventListener('abort', abort);
|
|
41
|
+
if (rejectAbort)
|
|
42
|
+
controller.signal.removeEventListener('abort', rejectAbort);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hasBoundedServiceTokenLifetime } from './serviceTokenLifetime.js';
|
|
2
|
+
import { accountActorChainSchema } from '@oxy.so/contracts';
|
|
1
3
|
import { jwtDecode } from 'jwt-decode';
|
|
2
4
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
3
5
|
import { logger } from '../logger/index.js';
|
|
@@ -405,22 +407,18 @@ export function createOxyMiddleware(host) {
|
|
|
405
407
|
return onError(error);
|
|
406
408
|
return res.status(401).json(error);
|
|
407
409
|
}
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (oxyUserId && tier === 'internal') {
|
|
413
|
-
req.userId = oxyUserId;
|
|
414
|
-
req.user = { id: oxyUserId };
|
|
415
|
-
req.serviceActingAs = { userId: oxyUserId, scopes: [] };
|
|
416
|
-
}
|
|
417
|
-
else if (oxyUserId) {
|
|
418
|
-
// C3: an EXTERNAL service may only act as a user when an explicit
|
|
410
|
+
// A service credential plus a user header is never a present user
|
|
411
|
+
// session, even for an internal tier. Offline delegation is explicit.
|
|
412
|
+
if (oxyUserId) {
|
|
413
|
+
// C3: every service may only act as a user when an explicit
|
|
419
414
|
// ServiceActingAs grant exists for that (appId, userId) pair.
|
|
420
415
|
// Without the grant we MUST refuse — silently attaching
|
|
421
416
|
// `req.userId = oxyUserId` would let any service impersonate
|
|
422
417
|
// any user simply by setting the header.
|
|
423
|
-
const grant = await host.verifyActingAs(appId, oxyUserId
|
|
418
|
+
const grant = await host.verifyActingAs(appId, oxyUserId, {
|
|
419
|
+
cache: false, credentialId: decoded.credentialId,
|
|
420
|
+
ownerAccountId: decoded.ownerAccountId, environment: decoded.environment,
|
|
421
|
+
});
|
|
424
422
|
if (!grant || !grant.authorized) {
|
|
425
423
|
logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
|
|
426
424
|
component: 'auth',
|
|
@@ -640,7 +638,43 @@ export function createOxyMiddleware(host) {
|
|
|
640
638
|
return onError(error);
|
|
641
639
|
return res.status(401).json(error);
|
|
642
640
|
}
|
|
641
|
+
// Who acted, and as whom — from the session authority's answer
|
|
642
|
+
// (#1520), never from a header or this undecoded token. An API that
|
|
643
|
+
// predates the field answers without it, and that is `null` here:
|
|
644
|
+
// "not reported", never a guess that the subject acted for itself.
|
|
645
|
+
// A chain naming a DIFFERENT effective account than the session
|
|
646
|
+
// resolved to has no benign cause and is refused like a user
|
|
647
|
+
// mismatch.
|
|
648
|
+
const actor = readActorChain(validationResult.actor);
|
|
649
|
+
if (actor === 'invalid' || (actor && actor.effectiveAccountId !== validatedUserId)) {
|
|
650
|
+
logger.warn('[oxy.auth] Session rejected — actor chain does not describe the session', {
|
|
651
|
+
component: 'auth',
|
|
652
|
+
method: 'auth',
|
|
653
|
+
validatedUserId,
|
|
654
|
+
});
|
|
655
|
+
recordRefusal(req, {
|
|
656
|
+
code: 'SESSION_ACTOR_MISMATCH',
|
|
657
|
+
stage: 'session',
|
|
658
|
+
reason: 'Session actor chain does not describe the validated session',
|
|
659
|
+
status: 401,
|
|
660
|
+
});
|
|
661
|
+
if (optional) {
|
|
662
|
+
req.userId = null;
|
|
663
|
+
req.user = null;
|
|
664
|
+
return next();
|
|
665
|
+
}
|
|
666
|
+
const error = {
|
|
667
|
+
error: 'SESSION_ACTOR_MISMATCH',
|
|
668
|
+
message: 'Session actor does not match the session',
|
|
669
|
+
code: 'SESSION_ACTOR_MISMATCH',
|
|
670
|
+
status: 401
|
|
671
|
+
};
|
|
672
|
+
if (onError)
|
|
673
|
+
return onError(error);
|
|
674
|
+
return res.status(401).json(error);
|
|
675
|
+
}
|
|
643
676
|
req.userId = validatedUserId;
|
|
677
|
+
req.oxyActor = actor;
|
|
644
678
|
req.accessToken = token;
|
|
645
679
|
req.sessionId = sessionId;
|
|
646
680
|
// Session validation already returned the full user, so `loadUser`
|
|
@@ -902,12 +936,6 @@ export function createOxyMiddleware(host) {
|
|
|
902
936
|
});
|
|
903
937
|
return;
|
|
904
938
|
}
|
|
905
|
-
// Oxy's own applications are trusted app to app; scopes are the
|
|
906
|
-
// external lane.
|
|
907
|
-
if (req.serviceApp.tier === 'internal') {
|
|
908
|
-
next();
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
939
|
const appHasScope = appScopes.includes(scope);
|
|
912
940
|
const delegationHasScope = delegatedScopes.includes(scope);
|
|
913
941
|
const hasRequiredScope = req.serviceActingAs
|
|
@@ -1123,6 +1151,9 @@ function getUserIdentityId(user) {
|
|
|
1123
1151
|
* service token because no claim binding existed.
|
|
1124
1152
|
*/
|
|
1125
1153
|
function verifyServiceTokenClaims(decoded, expected) {
|
|
1154
|
+
if (!hasBoundedServiceTokenLifetime(decoded)) {
|
|
1155
|
+
throw new ServiceTokenClaimError('Service token lifetime must be positive and at most 300 seconds');
|
|
1156
|
+
}
|
|
1126
1157
|
if (decoded.type !== 'service') {
|
|
1127
1158
|
throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
|
|
1128
1159
|
}
|
|
@@ -1139,3 +1170,14 @@ function verifyServiceTokenClaims(decoded, expected) {
|
|
|
1139
1170
|
throw new ServiceTokenClaimError(`Service token audience mismatch: expected '${expected.audience}', got '${String(aud)}'`);
|
|
1140
1171
|
}
|
|
1141
1172
|
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Parse the session authority's actor chain. `null` when the API did not send
|
|
1175
|
+
* one; `'invalid'` when it sent something that is not a chain, which the caller
|
|
1176
|
+
* refuses rather than ignores.
|
|
1177
|
+
*/
|
|
1178
|
+
function readActorChain(raw) {
|
|
1179
|
+
if (raw === undefined || raw === null)
|
|
1180
|
+
return null;
|
|
1181
|
+
const parsed = accountActorChainSchema.safeParse(raw);
|
|
1182
|
+
return parsed.success ? parsed.data : 'invalid';
|
|
1183
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server-side halves of the namespaces: what exists only with a service
|
|
3
|
+
* credential. `OxyServer` returns these from its namespace getters, so
|
|
4
|
+
* `server.assets.metadataByIds(...)` sits beside every client method of
|
|
5
|
+
* `oxy.assets`.
|
|
6
|
+
*/
|
|
7
|
+
import { capabilityTicketRequestSchema, foregroundExecutionAuthorizationInputSchema } from '@oxy.so/contracts';
|
|
1
8
|
import { AssetsApi } from '../api/assets.js';
|
|
2
9
|
import { NotificationsApi } from '../api/notifications.js';
|
|
3
10
|
import { LinkedAccountsApi } from '../api/linkedAccounts.js';
|
|
@@ -179,7 +186,57 @@ export class ServerLinkedAccountsApi extends LinkedAccountsApi {
|
|
|
179
186
|
return lane(this.ctx).request('GET', `/linked-accounts/by-user/${encodeURIComponent(userId)}`);
|
|
180
187
|
}
|
|
181
188
|
}
|
|
189
|
+
function agencyAuthorityOptions(signal) {
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
return { cache: false, deduplicate: false, retry: false, skipAuth: true, timeout: 5000, ...(signal ? { signal } : {}) };
|
|
192
|
+
}
|
|
182
193
|
export class ServerAgencyApi extends AgencyApi {
|
|
194
|
+
/** Live registry discovery with this service's existing capability scopes. */
|
|
195
|
+
async serviceCatalogs(input = {}) {
|
|
196
|
+
const path = `/capabilities/catalogs${input.appId === undefined ? '' : `?appId=${encodeURIComponent(input.appId)}`}`;
|
|
197
|
+
const result = await lane(this.ctx).request('GET', path, undefined, agencyAuthorityOptions(input.signal));
|
|
198
|
+
return result.registrations;
|
|
199
|
+
}
|
|
200
|
+
/** Existing Oxy execution authority is required; this call creates none. */
|
|
201
|
+
async issueCapabilityTicket(input, options = {}) {
|
|
202
|
+
return lane(this.ctx).request('POST', '/capabilities/tickets', capabilityTicketRequestSchema.parse(input), agencyAuthorityOptions(options.signal));
|
|
203
|
+
}
|
|
204
|
+
/** Receiving app authenticates live introspection using its own service credential. */
|
|
205
|
+
async introspectCapabilityTicket(ticket, options = {}) {
|
|
206
|
+
if (ticket.trim() === '')
|
|
207
|
+
throw new Error('A capability ticket is required');
|
|
208
|
+
return lane(this.ctx).request('POST', '/capabilities/tickets/introspect', { ticket }, agencyAuthorityOptions(options.signal));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Authenticates the existing user-authority route with the requester's bearer.
|
|
212
|
+
* The service bearer and attribution headers cannot substitute for that user.
|
|
213
|
+
* The token goes only to this client's configured Oxy authority; never retry.
|
|
214
|
+
*/
|
|
215
|
+
async createExecutionAuthorization(input, options) {
|
|
216
|
+
if (options.requesterToken === '' || /\s/.test(options.requesterToken))
|
|
217
|
+
throw new Error('A requester bearer is required');
|
|
218
|
+
const result = await this.ctx.request('POST', '/capabilities/execution-authorizations', input, {
|
|
219
|
+
...agencyAuthorityOptions(options.signal),
|
|
220
|
+
headers: { Authorization: `Bearer ${options.requesterToken}` },
|
|
221
|
+
});
|
|
222
|
+
return result.authorization;
|
|
223
|
+
}
|
|
224
|
+
/** Revoke a direct approval on its canonical requester-authenticated lane. */
|
|
225
|
+
async revokeExecutionAuthorization(authorizationId, options) {
|
|
226
|
+
options.signal?.throwIfAborted();
|
|
227
|
+
if (!authorizationId || authorizationId.length > 255 || /\s/.test(authorizationId))
|
|
228
|
+
throw new Error('An execution authorization id is required');
|
|
229
|
+
if (!options.requesterToken || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
|
|
230
|
+
throw new Error('A requester bearer is required');
|
|
231
|
+
await this.ctx.request('DELETE', `/capabilities/execution-authorizations/${encodeURIComponent(authorizationId)}`, undefined, { ...agencyAuthorityOptions(options.signal), headers: { Authorization: `Bearer ${options.requesterToken}` } });
|
|
232
|
+
}
|
|
233
|
+
/** Independent service proof plus present requester bearer, sent only to Oxy. */
|
|
234
|
+
async createForegroundExecutionAuthorization(input, options) {
|
|
235
|
+
if (options.requesterToken === '' || options.requesterToken.length > 16384 || /\s/.test(options.requesterToken))
|
|
236
|
+
throw new Error('A requester bearer is required');
|
|
237
|
+
const result = await lane(this.ctx).request('POST', '/capabilities/foreground-execution-authorizations', { ...foregroundExecutionAuthorizationInputSchema.parse(input), subjectToken: options.requesterToken }, agencyAuthorityOptions(options.signal));
|
|
238
|
+
return result.authorization;
|
|
239
|
+
}
|
|
183
240
|
/**
|
|
184
241
|
* Product backend → Oxy: trade the signed-in requester's access token for a
|
|
185
242
|
* one-use assertion naming `agentId` (ADR 0025), authenticated with this
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Approved I03 ceiling shared by issuance and both JWT verifiers. */
|
|
2
|
+
export const OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = 300;
|
|
3
|
+
/** Call only after signature verification; cache checks alone confer no authority. */
|
|
4
|
+
export function hasBoundedServiceTokenLifetime(claims, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
5
|
+
return typeof claims.iat === 'number' && Number.isSafeInteger(claims.iat)
|
|
6
|
+
&& typeof claims.exp === 'number' && Number.isSafeInteger(claims.exp)
|
|
7
|
+
&& claims.iat <= nowSeconds && claims.exp > claims.iat
|
|
8
|
+
&& claims.exp - claims.iat <= OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS;
|
|
9
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { deviceActivateResponseSchema, deviceDirectorySchema, deviceDirectorySyncSchema, deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, SESSION_ACCOUNTS_CHANGED_EVENT, sessionAccountsChangedEventSchema, } from '@oxy.so/contracts';
|
|
2
2
|
import { logger } from '../logger/index.js';
|
|
3
3
|
import { computeIdentityTag } from '../utils/cacheKey.js';
|
|
4
|
+
import { decodeTokenClaims } from '../utils/tokenClaims.js';
|
|
4
5
|
import { resolveActiveContext } from './deviceDirectory.js';
|
|
5
6
|
import { getSocketIO } from './socketLoader.js';
|
|
6
7
|
/**
|
|
@@ -34,6 +35,8 @@ export class SessionClient {
|
|
|
34
35
|
this.socket = null;
|
|
35
36
|
this.tokenUnsub = null;
|
|
36
37
|
this.started = false;
|
|
38
|
+
this.lifecycleGeneration = 0;
|
|
39
|
+
this.fullSignOutPersistence = null;
|
|
37
40
|
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
38
41
|
this.channel = null;
|
|
39
42
|
/** App-facing subscriptions to named server-pushed socket events. */
|
|
@@ -41,6 +44,35 @@ export class SessionClient {
|
|
|
41
44
|
/** Event names already bound on the CURRENT socket instance. */
|
|
42
45
|
this.boundServerEvents = new Set();
|
|
43
46
|
}
|
|
47
|
+
persistFullSignOut() {
|
|
48
|
+
const state = this.state;
|
|
49
|
+
if (!state || !this.options.onFullExplicitSignOut)
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
if (this.fullSignOutPersistence?.state === state)
|
|
52
|
+
return this.fullSignOutPersistence.pending;
|
|
53
|
+
const pending = this.options.onFullExplicitSignOut();
|
|
54
|
+
this.fullSignOutPersistence = { state, pending };
|
|
55
|
+
void pending.catch(() => {
|
|
56
|
+
if (this.fullSignOutPersistence?.pending === pending)
|
|
57
|
+
this.fullSignOutPersistence = null;
|
|
58
|
+
});
|
|
59
|
+
return pending;
|
|
60
|
+
}
|
|
61
|
+
async requestDevice(method, url, data, requestOptions) {
|
|
62
|
+
const generation = this.lifecycleGeneration;
|
|
63
|
+
const response = await this.host.makeRequest(method, url, data, requestOptions);
|
|
64
|
+
if (generation !== this.lifecycleGeneration)
|
|
65
|
+
throw new Error('Device session request was superseded');
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
/** Stop and discard only this client's local device projection; no server revocation. */
|
|
69
|
+
resetLocalState() {
|
|
70
|
+
this.lifecycleGeneration += 1;
|
|
71
|
+
this.stop();
|
|
72
|
+
this.state = null;
|
|
73
|
+
this.directory = null;
|
|
74
|
+
this.notify();
|
|
75
|
+
}
|
|
44
76
|
getState() {
|
|
45
77
|
return this.state;
|
|
46
78
|
}
|
|
@@ -163,6 +195,7 @@ export class SessionClient {
|
|
|
163
195
|
* cross-device push, a cold mint), not just the initial "no bearer yet" case.
|
|
164
196
|
*/
|
|
165
197
|
applyState(raw, origin = 'push', activeToken) {
|
|
198
|
+
const generation = this.lifecycleGeneration;
|
|
166
199
|
const next = safeParseContract(deviceSessionStateSchema, raw);
|
|
167
200
|
if (!next) {
|
|
168
201
|
logger.warn('[SessionClient] discarded invalid session state');
|
|
@@ -218,6 +251,8 @@ export class SessionClient {
|
|
|
218
251
|
next.accounts.length > 0 &&
|
|
219
252
|
(activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
|
|
220
253
|
const publish = () => {
|
|
254
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
255
|
+
return;
|
|
221
256
|
this.notify();
|
|
222
257
|
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
223
258
|
try {
|
|
@@ -236,6 +271,8 @@ export class SessionClient {
|
|
|
236
271
|
// (and this stays synchronous) for every client that never read a
|
|
237
272
|
// directory, i.e. the whole account lane.
|
|
238
273
|
const finishApply = () => {
|
|
274
|
+
if (generation !== this.lifecycleGeneration || this.state !== next)
|
|
275
|
+
return;
|
|
239
276
|
const settling = this.settleDirectory(next);
|
|
240
277
|
if (settling === null) {
|
|
241
278
|
publish();
|
|
@@ -243,8 +280,38 @@ export class SessionClient {
|
|
|
243
280
|
}
|
|
244
281
|
void settling.then(publish);
|
|
245
282
|
};
|
|
283
|
+
// A sibling's validated full removal is logout here too. Persist the local
|
|
284
|
+
// account-mode recovery suppression before publishing signed-out or running
|
|
285
|
+
// the empty-state refresh (which could otherwise challenge Commons again).
|
|
286
|
+
// Keep the push credential intact; a marker is not a destructive wipe.
|
|
287
|
+
// Unknown initial empty state, stale revisions and identity pins do not
|
|
288
|
+
// turn into logout intent. A newer state/lifecycle suppresses this publish.
|
|
289
|
+
const priorBearer = this.host.getAccessToken();
|
|
290
|
+
const bearerAccount = priorBearer ? decodeTokenClaims(priorBearer)?.userId : null;
|
|
291
|
+
// Warm cold boot may fetch authoritative empty state before it ever had a
|
|
292
|
+
// local projection. Its authenticated holder is history too. Decoding only
|
|
293
|
+
// identifies local history; authority remains the validated REST response.
|
|
294
|
+
const warmHolderEnded = previousState === null && origin === 'request' &&
|
|
295
|
+
typeof bearerAccount === 'string' && bearerAccount.length > 0 &&
|
|
296
|
+
this.host.getDeviceCredential()?.deviceId === next.deviceId;
|
|
297
|
+
if (((previousState?.deviceId === next.deviceId && previousState.accounts.length > 0) || warmHolderEnded) &&
|
|
298
|
+
next.accounts.length === 0 && pinnedAccountId === null && this.options.onFullExplicitSignOut) {
|
|
299
|
+
try {
|
|
300
|
+
void this.persistFullSignOut().then(finishApply).catch((error) => {
|
|
301
|
+
logger.error('[SessionClient] failed to persist received full sign-out', error);
|
|
302
|
+
finishApply();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
logger.error('[SessionClient] failed to persist received full sign-out', error);
|
|
307
|
+
finishApply();
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
246
311
|
if (needsMintBeforeNotify) {
|
|
247
312
|
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
313
|
+
if (generation !== this.lifecycleGeneration)
|
|
314
|
+
return;
|
|
248
315
|
logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
|
|
249
316
|
// Do NOT notify under a mismatched bearer. Revert to the last applied
|
|
250
317
|
// state so subscribers keep observing the account whose token is planted.
|
|
@@ -268,7 +335,7 @@ export class SessionClient {
|
|
|
268
335
|
* the token still needs to be planted. The account-match guard rejects a stale response for an
|
|
269
336
|
* account that is no longer active.
|
|
270
337
|
*/
|
|
271
|
-
applySync(raw) {
|
|
338
|
+
async applySync(raw) {
|
|
272
339
|
const sync = safeParseContract(deviceSessionSyncSchema, raw);
|
|
273
340
|
if (!sync) {
|
|
274
341
|
const parsed = deviceSessionSyncSchema.safeParse(raw);
|
|
@@ -285,6 +352,8 @@ export class SessionClient {
|
|
|
285
352
|
return;
|
|
286
353
|
}
|
|
287
354
|
this.commitSync(sync);
|
|
355
|
+
if (this.fullSignOutPersistence?.state === this.state)
|
|
356
|
+
await this.fullSignOutPersistence.pending;
|
|
288
357
|
}
|
|
289
358
|
/**
|
|
290
359
|
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
@@ -362,7 +431,7 @@ export class SessionClient {
|
|
|
362
431
|
}
|
|
363
432
|
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
364
433
|
async fetchDirectory() {
|
|
365
|
-
const res = await this.
|
|
434
|
+
const res = await this.requestDevice('GET', '/session/device/directory', undefined, { cache: false });
|
|
366
435
|
return this.applyDirectory(res);
|
|
367
436
|
}
|
|
368
437
|
/**
|
|
@@ -471,8 +540,8 @@ export class SessionClient {
|
|
|
471
540
|
}
|
|
472
541
|
}
|
|
473
542
|
async bootstrap() {
|
|
474
|
-
const res = await this.
|
|
475
|
-
this.applySync(res);
|
|
543
|
+
const res = await this.requestDevice('GET', '/session/device/state', undefined, { cache: false });
|
|
544
|
+
await this.applySync(res);
|
|
476
545
|
}
|
|
477
546
|
/**
|
|
478
547
|
* Adopt server-authoritative state already returned by another session
|
|
@@ -515,7 +584,7 @@ export class SessionClient {
|
|
|
515
584
|
* compatibility path for callers still keyed on account ids.
|
|
516
585
|
*/
|
|
517
586
|
async activateContext(contextId) {
|
|
518
|
-
const res = await this.
|
|
587
|
+
const res = await this.requestDevice('POST', '/session/device/activate', { contextId }, { cache: false });
|
|
519
588
|
const activation = safeParseContract(deviceActivateResponseSchema, res);
|
|
520
589
|
if (!activation) {
|
|
521
590
|
logger.warn('[SessionClient] discarded invalid activation response');
|
|
@@ -541,8 +610,8 @@ export class SessionClient {
|
|
|
541
610
|
}
|
|
542
611
|
}
|
|
543
612
|
async switchAccount(accountId) {
|
|
544
|
-
const res = await this.
|
|
545
|
-
this.applySync(res);
|
|
613
|
+
const res = await this.requestDevice('POST', '/session/device/switch', { accountId }, { cache: false });
|
|
614
|
+
await this.applySync(res);
|
|
546
615
|
this.postCommitPing();
|
|
547
616
|
}
|
|
548
617
|
/**
|
|
@@ -557,9 +626,11 @@ export class SessionClient {
|
|
|
557
626
|
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
558
627
|
*/
|
|
559
628
|
async signOut(target) {
|
|
560
|
-
const res = await this.
|
|
561
|
-
this.applySync(res);
|
|
629
|
+
const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
|
|
630
|
+
await this.applySync(res);
|
|
562
631
|
this.postCommitPing();
|
|
632
|
+
if (this.state?.accounts.length === 0)
|
|
633
|
+
await this.persistFullSignOut();
|
|
563
634
|
}
|
|
564
635
|
/**
|
|
565
636
|
* Remove ONE `principal → account` pair, and only that pair.
|
|
@@ -598,7 +669,7 @@ export class SessionClient {
|
|
|
598
669
|
* applied this revision.
|
|
599
670
|
*/
|
|
600
671
|
async removeFromDevice(target) {
|
|
601
|
-
const res = await this.
|
|
672
|
+
const res = await this.requestDevice('POST', '/session/device/signout', target, { cache: false });
|
|
602
673
|
const removal = safeParseContract(deviceDirectorySyncSchema, res);
|
|
603
674
|
if (!removal) {
|
|
604
675
|
logger.warn('[SessionClient] discarded invalid device removal response');
|
|
@@ -606,6 +677,8 @@ export class SessionClient {
|
|
|
606
677
|
}
|
|
607
678
|
const directoryApplied = this.applyDirectory(removal.directory);
|
|
608
679
|
const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
|
|
680
|
+
if (this.fullSignOutPersistence?.state === this.state)
|
|
681
|
+
await this.fullSignOutPersistence.pending;
|
|
609
682
|
// `commitSync` publishes whenever the flat state moved. When only the
|
|
610
683
|
// directory did — a socket push already applied this revision — the
|
|
611
684
|
// directory half would otherwise never reach a subscriber.
|
|
@@ -613,10 +686,12 @@ export class SessionClient {
|
|
|
613
686
|
this.notify();
|
|
614
687
|
}
|
|
615
688
|
this.postCommitPing();
|
|
689
|
+
if (this.state?.accounts.length === 0)
|
|
690
|
+
await this.persistFullSignOut();
|
|
616
691
|
}
|
|
617
692
|
async addCurrentAccount() {
|
|
618
|
-
const res = await this.
|
|
619
|
-
this.applySync(res);
|
|
693
|
+
const res = await this.requestDevice('POST', '/session/device/add', undefined, { cache: false });
|
|
694
|
+
await this.applySync(res);
|
|
620
695
|
this.postCommitPing();
|
|
621
696
|
}
|
|
622
697
|
/**
|
|
@@ -718,6 +793,7 @@ export class SessionClient {
|
|
|
718
793
|
const deviceCredential = this.host.getDeviceCredential();
|
|
719
794
|
if (!token && !deviceCredential)
|
|
720
795
|
return;
|
|
796
|
+
const generation = this.lifecycleGeneration;
|
|
721
797
|
const socket = io(this.host.getBaseURL(), {
|
|
722
798
|
transports: ['websocket'],
|
|
723
799
|
autoConnect: true,
|
|
@@ -742,6 +818,8 @@ export class SessionClient {
|
|
|
742
818
|
socket.on('session_state', (payload) => {
|
|
743
819
|
// A socket broadcast is a `push`-origin — potentially transient, so an
|
|
744
820
|
// empty state here must not erase the durable device credential.
|
|
821
|
+
if (generation !== this.lifecycleGeneration)
|
|
822
|
+
return;
|
|
745
823
|
const applied = this.applyState(payload, 'push');
|
|
746
824
|
if (!applied)
|
|
747
825
|
return;
|