@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
|
@@ -25,7 +25,7 @@ export type { AccountDelegationAccess, AccountDelegationNode, } from './accountD
|
|
|
25
25
|
export { AccountDialogController, createAccountDialogController, } from './accountDialogController';
|
|
26
26
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, ContextChoiceOutcome, SignInFailureReason, SignInFlowPhase, SignInFlowState, SignInProgress, } from './accountDialogController';
|
|
27
27
|
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './authStateStore';
|
|
28
|
-
export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './authStateStore';
|
|
28
|
+
export type { PersistedAuthState, AuthStateStore, AuthStateWriteGuard, NativeKeyValueStorage, } from './authStateStore';
|
|
29
29
|
export { createSharedMirroringAuthStateStore, decideSharedDeviceJoin, decideSharedDevicePublish, normalizeSharedDeviceSessionRead, publishProvenDeviceCredential, readLocalDeviceCredential, } from './sharedDeviceCredential';
|
|
30
30
|
export type { SharedDeviceCredential, SharedDeviceCredentialRead, SharedDeviceCredentialStore, SharedDeviceJoinDecision, SharedDeviceJoinSkipReason, SharedDevicePublishDecision, SharedDevicePublishOutcome, SharedDevicePublishSkipReason, } from './sharedDeviceCredential';
|
|
31
31
|
export { createWebIdentityPinStore, createNativeIdentityPinStore, createMemoryIdentityPinStore, identityPinMatches, IDENTITY_PIN_STORAGE_KEY, } from './identityPin';
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import type { DeviceSessionState } from '@oxy.so/contracts';
|
|
25
25
|
import type { OxyServices } from '../OxyServices';
|
|
26
26
|
import type { AuthRefreshHandler } from '../HttpService';
|
|
27
|
-
import type
|
|
27
|
+
import { type AuthStateStore } from './authStateStore';
|
|
28
|
+
import type { SharedDeviceCredentialStore } from './sharedDeviceCredential';
|
|
28
29
|
import type { IdentityPin } from './identityPin';
|
|
29
30
|
import { type IdentityBinding } from './identitySession';
|
|
30
31
|
/**
|
|
@@ -68,6 +69,10 @@ export interface RefreshDeps {
|
|
|
68
69
|
* Commons-identity one.
|
|
69
70
|
*/
|
|
70
71
|
identity?: IdentityBinding;
|
|
72
|
+
/** Native ACCOUNT join point. A live local holder always wins. */
|
|
73
|
+
sharedDeviceCredential?: SharedDeviceCredentialStore;
|
|
74
|
+
/** A replaced/unmounted provider cannot commit a pending refresh. */
|
|
75
|
+
isCurrent?: () => boolean;
|
|
71
76
|
}
|
|
72
77
|
/**
|
|
73
78
|
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
@@ -127,9 +132,13 @@ export type DeviceSecretMintOutcome = {
|
|
|
127
132
|
*
|
|
128
133
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
129
134
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
130
|
-
* planting.
|
|
135
|
+
* planting. Apart from the account-mode logout marker below, failure mutations
|
|
136
|
+
* belong to the caller, which
|
|
131
137
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
132
|
-
* status.
|
|
138
|
+
* status. A native account store with a suppression marker records a current
|
|
139
|
+
* `no_active_session`, or an invalid secret with prior authenticated session
|
|
140
|
+
* history, records durable ACCOUNT logout intent; it never deletes that holder.
|
|
141
|
+
* Error classification uses the existing 401 message markers.
|
|
133
142
|
*
|
|
134
143
|
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
135
144
|
* `accountId` (so the server mints that account's token without touching
|
|
@@ -142,16 +151,32 @@ export declare function refreshDeviceSecretArm(deps: {
|
|
|
142
151
|
store: AuthStateStore;
|
|
143
152
|
/** The identity pin, when this client is identity-bound. */
|
|
144
153
|
pin?: IdentityPin | null;
|
|
154
|
+
isCurrent?: () => boolean;
|
|
155
|
+
}): Promise<DeviceSecretMintOutcome>;
|
|
156
|
+
/**
|
|
157
|
+
* Prove a shared native ACCOUNT holder before replacing local bytes. Callers
|
|
158
|
+
* may replace a local credential only after its explicit mint rejection. The
|
|
159
|
+
* optional guarded storage seam makes custom stores fail closed on adoption.
|
|
160
|
+
* This lane never clears explicit sign-out intent or uses an identity key.
|
|
161
|
+
*/
|
|
162
|
+
export declare function refreshSharedDeviceArm(deps: {
|
|
163
|
+
oxy: OxyServices;
|
|
164
|
+
store: AuthStateStore;
|
|
165
|
+
shared: SharedDeviceCredentialStore;
|
|
166
|
+
rejectedLocalHolder?: boolean;
|
|
167
|
+
isCurrent?: () => boolean;
|
|
145
168
|
}): Promise<DeviceSecretMintOutcome>;
|
|
146
169
|
/**
|
|
147
170
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
148
171
|
* when no arm could produce one.
|
|
149
172
|
*
|
|
150
173
|
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
151
|
-
* from the persisted `deviceId` + `deviceSecret`.
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
174
|
+
* from the persisted `deviceId` + `deviceSecret`. An authenticated native
|
|
175
|
+
* holder's invalid-secret or `no_active_session` verdict suppresses key recovery
|
|
176
|
+
* while permitting a separately proved new shared holder. The latter verdict
|
|
177
|
+
* retains that holder and ends ACCOUNT recovery without a new key challenge.
|
|
178
|
+
* Classification still uses the existing 401 message markers, not a new parser.
|
|
179
|
+
* A transient error — or a durable-persist failure — leaves the store and
|
|
155
180
|
* returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
|
|
156
181
|
*
|
|
157
182
|
* Arm 2 (native Commons identity): when the secret is absent or was just
|
|
@@ -189,7 +189,9 @@ export declare function publishProvenDeviceCredential(deps: {
|
|
|
189
189
|
* seam, no lane left out, and no new call site to forget.
|
|
190
190
|
* - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
|
|
191
191
|
* a storage primitive, and would run on every `load()`. Adoption is an
|
|
192
|
-
* explicit
|
|
192
|
+
* explicit cold-boot/resume recovery instead; an existing local holder must
|
|
193
|
+
* first be rejected by the server, and a new shared holder must mint before
|
|
194
|
+
* conditional persistence.
|
|
193
195
|
*
|
|
194
196
|
* `clear()` deliberately does NOT clear the shared slot. This app signing out is
|
|
195
197
|
* not authority over the device-wide join point: other apps may still be signed
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
* Official first-party web origin allowlist — shared by OAuth redirect
|
|
3
|
-
* validation and the server-side trusted-origin checks.
|
|
4
|
-
*/
|
|
1
|
+
export { isLoopbackOrigin } from '@oxy.so/contracts';
|
|
5
2
|
/**
|
|
6
3
|
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
7
4
|
* or `[::1]` on any port, http or https).
|
|
8
5
|
*/
|
|
9
|
-
export declare function isLoopbackOrigin(origin: string): boolean;
|
|
10
6
|
/** Whether an origin belongs to the official Oxy web ecosystem. */
|
|
11
7
|
export declare function isOfficialWebOrigin(origin: string): boolean;
|
|
12
8
|
/** @deprecated Use {@link isOfficialWebOrigin}. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -179,8 +179,8 @@
|
|
|
179
179
|
"@oxy.so/telemetry": "^1.2.0",
|
|
180
180
|
"@noble/ciphers": "^1.3.0",
|
|
181
181
|
"@noble/hashes": "^1.8.0",
|
|
182
|
-
"@oxy.so/contracts": "^4.
|
|
183
|
-
"@oxy.so/protocol": "^1.2.
|
|
182
|
+
"@oxy.so/contracts": "^4.9.1",
|
|
183
|
+
"@oxy.so/protocol": "^1.2.2",
|
|
184
184
|
"@scure/bip39": "^1.6.0",
|
|
185
185
|
"buffer": "^6.0.3",
|
|
186
186
|
"express-rate-limit": "^8.6.0",
|
|
@@ -192,9 +192,9 @@
|
|
|
192
192
|
"zod": "^3.25.64"
|
|
193
193
|
},
|
|
194
194
|
"peerDependencies": {
|
|
195
|
-
"@react-native-async-storage/async-storage": "
|
|
196
|
-
"expo-crypto": "
|
|
197
|
-
"expo-secure-store": "
|
|
195
|
+
"@react-native-async-storage/async-storage": "<3.0.0",
|
|
196
|
+
"expo-crypto": "<58.0.0",
|
|
197
|
+
"expo-secure-store": "<58.0.0",
|
|
198
198
|
"express": "^4.0.0"
|
|
199
199
|
},
|
|
200
200
|
"peerDependenciesMeta": {
|
package/src/HttpService.ts
CHANGED
|
@@ -37,20 +37,54 @@ export type AccessTokenProvider = () => string | null;
|
|
|
37
37
|
*/
|
|
38
38
|
export type AnonymousAuthProvider = () => Promise<string | null>;
|
|
39
39
|
|
|
40
|
+
/** Comparison only fences local async work; it never validates token authority. */
|
|
41
|
+
function sameTokenSession(left: string | null, right: string | null): boolean {
|
|
42
|
+
if (left === right) return true;
|
|
43
|
+
if (!left || !right) return false;
|
|
44
|
+
const a = decodeTokenClaims(left);
|
|
45
|
+
const b = decodeTokenClaims(right);
|
|
46
|
+
if (!a || !b || !(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub)) return false;
|
|
47
|
+
// A refresh may rotate exp/iat/jti while preserving its account and session.
|
|
48
|
+
// Opaque/malformed context fields cannot establish that equivalence.
|
|
49
|
+
return ['userId', 'id', 'sub', 'sessionId', 'operatorId', 'clientId', 'appId', 'authMethodId'].every((key) =>
|
|
50
|
+
(a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Cancel this wait only; the shared mint continues for other callers. */
|
|
54
|
+
function awaitRequestSignal<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
55
|
+
if (!signal) return pending;
|
|
56
|
+
return new Promise<T>((resolve, reject) => {
|
|
57
|
+
const onAbort = (): void => { reject(createCancelledError()); };
|
|
58
|
+
if (signal.aborted) onAbort();
|
|
59
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
60
|
+
// Both handlers remain attached even after cancellation, so a late shared
|
|
61
|
+
// failure is observed without reviving this request or becoming unhandled.
|
|
62
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A trusted platform transport, for example Expo's streaming fetch. */
|
|
67
|
+
export type ResponseTransport = (input: string, init: RequestInit) => Promise<Response>;
|
|
68
|
+
|
|
40
69
|
/**
|
|
41
|
-
* A low-level
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
70
|
+
* A low-level same-origin request whose response body remains unread.
|
|
71
|
+
* Only absent/string request bodies can be replayed after a 401. Multipart,
|
|
72
|
+
* binary and stream bodies are sent once, even if their platform can replay them.
|
|
73
|
+
* The caller owns response parsing and the AbortSignal for its full lifetime.
|
|
45
74
|
*/
|
|
46
|
-
export interface
|
|
75
|
+
export interface ResponseRequest {
|
|
47
76
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
48
77
|
url: string;
|
|
49
|
-
body?:
|
|
50
|
-
headers?:
|
|
78
|
+
body?: BodyInit;
|
|
79
|
+
headers?: HeadersInit;
|
|
51
80
|
signal?: AbortSignal;
|
|
81
|
+
/** Must honour the supplied URL, headers, signal and redirect policy. */
|
|
82
|
+
fetch?: ResponseTransport;
|
|
52
83
|
}
|
|
53
84
|
|
|
85
|
+
/** Same response contract, but rejects locally when the SDK has no bearer. */
|
|
86
|
+
export type AuthenticatedResponseRequest = ResponseRequest;
|
|
87
|
+
|
|
54
88
|
/**
|
|
55
89
|
* Structural type that captures the multipart-write surface every supported
|
|
56
90
|
* FormData implementation exposes (browser, React Native, Node `form-data`
|
|
@@ -464,8 +498,7 @@ export class HttpService {
|
|
|
464
498
|
|
|
465
499
|
if (providedToken) {
|
|
466
500
|
if (providedToken !== currentToken) {
|
|
467
|
-
this.
|
|
468
|
-
this.notifyTokenChange();
|
|
501
|
+
this.setTokens(providedToken);
|
|
469
502
|
}
|
|
470
503
|
return providedToken;
|
|
471
504
|
}
|
|
@@ -1282,8 +1315,12 @@ export class HttpService {
|
|
|
1282
1315
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1283
1316
|
.then((newToken) => {
|
|
1284
1317
|
if (epoch !== this.sessionEpoch) {
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1318
|
+
// A guarded refresh handler can itself adopt a new active context.
|
|
1319
|
+
// Acknowledge only an already-installed, exact result; never plant
|
|
1320
|
+
// a stale return value over a concurrent switch/logout.
|
|
1321
|
+
if (newToken && !this.sessionEnded && this.tokenStore.getAccessToken() === newToken) {
|
|
1322
|
+
return newToken;
|
|
1323
|
+
}
|
|
1287
1324
|
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
1288
1325
|
return null;
|
|
1289
1326
|
}
|
|
@@ -1294,8 +1331,7 @@ export class HttpService {
|
|
|
1294
1331
|
// A token is planted again, so there is a session again.
|
|
1295
1332
|
this.sessionEnded = false;
|
|
1296
1333
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1297
|
-
this.
|
|
1298
|
-
this.notifyTokenChange();
|
|
1334
|
+
this.setTokens(newToken);
|
|
1299
1335
|
}
|
|
1300
1336
|
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1301
1337
|
// A success clears the failure timestamp so the next refresh is never
|
|
@@ -1463,56 +1499,101 @@ export class HttpService {
|
|
|
1463
1499
|
* abort signal defines the lifetime of a healthy stream.
|
|
1464
1500
|
*/
|
|
1465
1501
|
async requestAuthenticatedResponse(config: AuthenticatedResponseRequest): Promise<Response> {
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
throw new OxyAuthenticationError(
|
|
1469
|
-
'An active Oxy session is required for this request',
|
|
1470
|
-
'AUTH_REQUIRED',
|
|
1471
|
-
);
|
|
1472
|
-
}
|
|
1502
|
+
return this.requestRawResponse(config, true);
|
|
1503
|
+
}
|
|
1473
1504
|
|
|
1474
|
-
|
|
1505
|
+
/**
|
|
1506
|
+
* Raw response for public or authenticated app endpoints. The SDK supplies
|
|
1507
|
+
* its current bearer when available; caller headers never supply authority.
|
|
1508
|
+
* No caching, envelope transformation or transport-error retry is performed.
|
|
1509
|
+
*/
|
|
1510
|
+
async requestResponse(config: ResponseRequest): Promise<Response> {
|
|
1511
|
+
return this.requestRawResponse(config, false);
|
|
1475
1512
|
}
|
|
1476
1513
|
|
|
1477
|
-
private async
|
|
1478
|
-
|
|
1479
|
-
|
|
1514
|
+
private async requestRawResponse(config: ResponseRequest, requireAuth: boolean): Promise<Response> {
|
|
1515
|
+
// Validate before obtaining a bearer: an unrelated origin must not trigger
|
|
1516
|
+
// an auth mint, even when the trusted transport accepts absolute URLs.
|
|
1517
|
+
const target = new URL(this.buildURL(config.url));
|
|
1518
|
+
const base = new URL(this.baseURL);
|
|
1519
|
+
if (target.origin !== base.origin || !['http:', 'https:'].includes(target.protocol)
|
|
1520
|
+
|| target.username || target.password) {
|
|
1521
|
+
throw new Error('Raw response requests must target the configured API origin');
|
|
1522
|
+
}
|
|
1523
|
+
if (config.signal?.aborted) throw createCancelledError();
|
|
1524
|
+
// Snapshot caller-owned options once, including headers, before any await.
|
|
1525
|
+
const request = { ...config, headers: new Headers(config.headers) };
|
|
1526
|
+
const contextToken = this.syncAccessTokenFromProvider();
|
|
1527
|
+
const beforeAuthEpoch = this.sessionEpoch;
|
|
1528
|
+
const authHeader = await awaitRequestSignal(this.getAuthHeader(), config.signal);
|
|
1529
|
+
if (beforeAuthEpoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
|
|
1530
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1531
|
+
}
|
|
1532
|
+
if (config.signal?.aborted) throw createCancelledError();
|
|
1533
|
+
if (requireAuth && !authHeader) {
|
|
1534
|
+
throw new OxyAuthenticationError('An active Oxy session is required for this request', 'AUTH_REQUIRED');
|
|
1535
|
+
}
|
|
1536
|
+
return this.requestResponseAttempt(request, target.href, authHeader, false, this.sessionEpoch, contextToken);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
private async requestResponseAttempt(
|
|
1540
|
+
config: ResponseRequest,
|
|
1541
|
+
fullUrl: string,
|
|
1542
|
+
authHeader: string | null,
|
|
1480
1543
|
isAuthRetry: boolean,
|
|
1544
|
+
epoch: number,
|
|
1545
|
+
contextToken: string | null,
|
|
1481
1546
|
): Promise<Response> {
|
|
1547
|
+
const assertCurrent = (): void => {
|
|
1548
|
+
this.syncAccessTokenFromProvider();
|
|
1549
|
+
if (epoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
|
|
1550
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1482
1553
|
const startTime = Date.now();
|
|
1554
|
+
if (config.signal?.aborted) throw createCancelledError();
|
|
1555
|
+
assertCurrent();
|
|
1483
1556
|
const headers = new Headers(config.headers);
|
|
1484
|
-
if (!headers.has('Accept'))
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
// the bearer with a different session or leak one across linked apps.
|
|
1489
|
-
headers.set('Authorization', authHeader);
|
|
1490
|
-
// Never awaited: the PoP is a hint, and waiting on its trace fetch put up
|
|
1491
|
-
// to a second in front of requests.
|
|
1557
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
1558
|
+
// Never accept ambient credentials or another authority from a caller.
|
|
1559
|
+
for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization']) headers.delete(name);
|
|
1560
|
+
if (authHeader) headers.set('Authorization', authHeader);
|
|
1492
1561
|
const edgeRegionHeader = peekBrowserEdgeRegionHeader();
|
|
1493
1562
|
for (const [name, value] of Object.entries(edgeRegionHeader)) headers.set(name, value);
|
|
1494
1563
|
const activityIdHeader = getBrowserActivityIdHeader();
|
|
1495
1564
|
for (const [name, value] of Object.entries(activityIdHeader)) headers.set(name, value);
|
|
1496
1565
|
|
|
1497
1566
|
try {
|
|
1498
|
-
const
|
|
1499
|
-
const
|
|
1567
|
+
const body = config.method === 'GET' ? undefined : config.body;
|
|
1568
|
+
const init: RequestInit & { duplex?: 'half' } = {
|
|
1500
1569
|
method: config.method,
|
|
1501
1570
|
headers,
|
|
1502
|
-
body
|
|
1571
|
+
body,
|
|
1503
1572
|
signal: config.signal,
|
|
1504
|
-
credentials:
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1573
|
+
credentials: 'omit',
|
|
1574
|
+
redirect: 'error',
|
|
1575
|
+
};
|
|
1576
|
+
// Node fetch requires this flag for streaming uploads; ordinary and
|
|
1577
|
+
// multipart bodies keep the platform's existing body serialization.
|
|
1578
|
+
if (body && typeof body === 'object' && 'getReader' in body) init.duplex = 'half';
|
|
1579
|
+
const response = await (config.fetch ?? globalThis.fetch)(fullUrl, init);
|
|
1580
|
+
try { assertCurrent(); } catch (error) { await response.body?.cancel(); throw error; }
|
|
1581
|
+
const replayable = body === undefined || typeof body === 'string';
|
|
1582
|
+
if (response.status === 401 && !isAuthRetry && authHeader && replayable && !config.signal?.aborted) {
|
|
1583
|
+
let refreshed: string | null;
|
|
1584
|
+
try {
|
|
1585
|
+
refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), config.signal);
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
void response.body?.cancel().catch(() => undefined);
|
|
1588
|
+
throw error;
|
|
1589
|
+
}
|
|
1590
|
+
try { assertCurrent(); } catch (error) { await response.body?.cancel(); throw error; }
|
|
1509
1591
|
if (refreshed) {
|
|
1510
1592
|
await response.body?.cancel();
|
|
1511
|
-
|
|
1593
|
+
if (config.signal?.aborted) throw createCancelledError();
|
|
1594
|
+
return this.requestResponseAttempt(config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken);
|
|
1512
1595
|
}
|
|
1513
|
-
|
|
1514
|
-
this.tokenStore.clearTokens();
|
|
1515
|
-
this.notifyTokenChange();
|
|
1596
|
+
this.clearTokens();
|
|
1516
1597
|
}
|
|
1517
1598
|
|
|
1518
1599
|
const duration = Date.now() - startTime;
|
|
@@ -1523,8 +1604,11 @@ export class HttpService {
|
|
|
1523
1604
|
const duration = Date.now() - startTime;
|
|
1524
1605
|
this.updateMetrics(false, duration);
|
|
1525
1606
|
this.config.onRequestEnd?.(config.url, config.method, duration, false);
|
|
1526
|
-
const normalizedError =
|
|
1607
|
+
const normalizedError = config.signal?.aborted || isCancelledError(error)
|
|
1608
|
+
? createCancelledError()
|
|
1609
|
+
: error instanceof Error ? error : new Error(String(error));
|
|
1527
1610
|
this.config.onRequestError?.(config.url, config.method, normalizedError);
|
|
1611
|
+
if (isCancelledError(normalizedError) || normalizedError instanceof OxyAuthenticationError) throw normalizedError;
|
|
1528
1612
|
throw handleHttpError(normalizedError);
|
|
1529
1613
|
}
|
|
1530
1614
|
}
|
|
@@ -1552,6 +1636,8 @@ export class HttpService {
|
|
|
1552
1636
|
|
|
1553
1637
|
// Token management
|
|
1554
1638
|
setTokens(accessToken: string): void {
|
|
1639
|
+
const current = this.tokenStore.getAccessToken();
|
|
1640
|
+
if (current && !sameTokenSession(current, accessToken)) this.sessionEpoch += 1;
|
|
1555
1641
|
this.sessionEnded = false;
|
|
1556
1642
|
this.tokenStore.setTokens(accessToken);
|
|
1557
1643
|
this.notifyTokenChange();
|
|
@@ -1561,18 +1647,19 @@ export class HttpService {
|
|
|
1561
1647
|
* End the local session: clear the bearer and abandon every re-mint already
|
|
1562
1648
|
* in flight, so none of them can plant a token after the sign-out.
|
|
1563
1649
|
*
|
|
1564
|
-
* {@link clearTokens}
|
|
1565
|
-
*
|
|
1566
|
-
* refresh started a moment earlier would finish and plant a new one.
|
|
1650
|
+
* Unlike {@link clearTokens}, this also marks the session as ended and
|
|
1651
|
+
* invalidates pending re-mints even when no bearer is currently present.
|
|
1567
1652
|
*/
|
|
1568
1653
|
endSession(): void {
|
|
1569
|
-
this.sessionEpoch += 1;
|
|
1654
|
+
if (!this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
|
|
1570
1655
|
this.sessionEnded = true;
|
|
1571
1656
|
this.clearTokens();
|
|
1572
1657
|
}
|
|
1573
1658
|
|
|
1574
1659
|
/**
|
|
1575
|
-
* The current
|
|
1660
|
+
* The current context epoch: account/session switches and bearer removal
|
|
1661
|
+
* invalidate pending work; rotation within the same context preserves it.
|
|
1662
|
+
* A re-mint lane that
|
|
1576
1663
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1577
1664
|
*/
|
|
1578
1665
|
getSessionEpoch(): number {
|
|
@@ -1604,6 +1691,7 @@ export class HttpService {
|
|
|
1604
1691
|
}
|
|
1605
1692
|
|
|
1606
1693
|
clearTokens(): void {
|
|
1694
|
+
if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
|
|
1607
1695
|
this.tokenStore.clearTokens();
|
|
1608
1696
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1609
1697
|
// different user could never read these entries, but a logged-out client
|
package/src/api/accounts.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* no per-request "acting-as" header. Identity is carried by the session/token,
|
|
18
18
|
* so a switch propagates through reload and cross-domain exactly like a login.
|
|
19
19
|
*/
|
|
20
|
+
import { agentKeyOperationSchema, executeAgentKeyOperationSchema, agentKeyOperationChallengeSchema,
|
|
21
|
+
agentKeyOperationResultSchema, agentKeyListResponseSchema, type AgentKeyOperation, type AgentKeyOperationProof } from '@oxy.so/contracts';
|
|
20
22
|
import type { User } from '../models/interfaces';
|
|
21
23
|
import type { AccountCategoryId, AccountKind, ChildAccountKind } from '@oxy.so/contracts';
|
|
22
24
|
import type { SessionLoginResponse } from '../models/session';
|
|
@@ -413,12 +415,32 @@ export class AccountMembersApi {
|
|
|
413
415
|
}
|
|
414
416
|
}
|
|
415
417
|
|
|
418
|
+
/** Current account governors and autonomous rotation use action-bound proofs. */
|
|
419
|
+
export class AccountAgentKeysApi {
|
|
420
|
+
constructor(private readonly ctx: OxyContext) {}
|
|
421
|
+
async list(accountId: string) {
|
|
422
|
+
return agentKeyListResponseSchema.parse(await this.ctx.request<unknown>('GET', `/accounts/${enc(accountId)}/agent-keys`, undefined, { cache: false }));
|
|
423
|
+
}
|
|
424
|
+
async requestChallenge(accountId: string, operation: AgentKeyOperation) {
|
|
425
|
+
const response = await this.ctx.request<unknown>('POST', `/accounts/${enc(accountId)}/agent-keys/challenge`,
|
|
426
|
+
agentKeyOperationSchema.parse(operation), { cache: false });
|
|
427
|
+
return agentKeyOperationChallengeSchema.parse(response);
|
|
428
|
+
}
|
|
429
|
+
async execute(accountId: string, operation: AgentKeyOperation, proof: AgentKeyOperationProof) {
|
|
430
|
+
const response = await this.ctx.request<unknown>('POST', `/accounts/${enc(accountId)}/agent-keys/execute`,
|
|
431
|
+
executeAgentKeyOperationSchema.parse({ operation, proof }), { cache: false });
|
|
432
|
+
return agentKeyOperationResultSchema.parse(response);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
416
436
|
export class AccountsApi {
|
|
417
437
|
/** Who can act on an account, and with what role. */
|
|
418
438
|
readonly members: AccountMembersApi;
|
|
439
|
+
readonly agentKeys: AccountAgentKeysApi;
|
|
419
440
|
|
|
420
441
|
constructor(private readonly ctx: OxyContext) {
|
|
421
442
|
this.members = new AccountMembersApi(ctx);
|
|
443
|
+
this.agentKeys = new AccountAgentKeysApi(ctx);
|
|
422
444
|
}
|
|
423
445
|
|
|
424
446
|
/**
|
package/src/api/agency.ts
CHANGED
|
@@ -83,7 +83,9 @@ export interface CapabilityExecutionAuthorization {
|
|
|
83
83
|
ownerAccountId: string;
|
|
84
84
|
coordinatorApplicationId: string;
|
|
85
85
|
coordinatorCredentialId: string;
|
|
86
|
-
actorType: 'alia' | 'agent';
|
|
86
|
+
actorType: 'alia' | 'agent' | 'requester';
|
|
87
|
+
requesterSessionId?: string | null;
|
|
88
|
+
requesterSessionBindingDigest?: string | null;
|
|
87
89
|
actorAccountId: string | null;
|
|
88
90
|
resourceApp: string;
|
|
89
91
|
effectiveAccountId: string;
|
package/src/api/apps.ts
CHANGED
|
@@ -565,13 +565,13 @@ export class AppsApi {
|
|
|
565
565
|
* consent, authorize, and device-flow approval screens before any session
|
|
566
566
|
* exists.
|
|
567
567
|
*/
|
|
568
|
-
async getPublic(clientId: string): Promise<PublicApplication> {
|
|
568
|
+
async getPublic(clientId: string, options: { cache?: boolean } = {}): Promise<PublicApplication> {
|
|
569
569
|
const res = await this.ctx.request<{ application: PublicApplication }>(
|
|
570
570
|
'GET',
|
|
571
571
|
`/auth/oauth/client/${enc(clientId)}`,
|
|
572
572
|
undefined,
|
|
573
573
|
// Public client metadata (pre-session consent UI) — skip the bearer preflight.
|
|
574
|
-
{ cache: true, cacheTTL: MEDIUM_TTL, skipAuth: true },
|
|
574
|
+
{ cache: options.cache ?? true, cacheTTL: MEDIUM_TTL, skipAuth: true },
|
|
575
575
|
);
|
|
576
576
|
return res.application;
|
|
577
577
|
}
|
package/src/api/auth.ts
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
import type { User } from '../models/interfaces';
|
|
19
19
|
import type { CommonsDenyReason, DeviceProof, LoginResult } from '@oxy.so/contracts';
|
|
20
20
|
import {
|
|
21
|
+
agentProofClaimsSchema,
|
|
22
|
+
agentChallengeRequestSchema,
|
|
23
|
+
agentVerifyRequestSchema,
|
|
24
|
+
type AgentProofClaims,
|
|
25
|
+
type AgentSignature,
|
|
21
26
|
emailSignInLinkResponseSchema,
|
|
22
27
|
emailSignInPendingSchema,
|
|
23
28
|
emailSignInStartResponseSchema,
|
|
@@ -693,12 +698,13 @@ export class AuthOAuthApi {
|
|
|
693
698
|
* out, so the app can run the OAuth flow again. It never degrades into a
|
|
694
699
|
* session that looks alive and cannot refresh.
|
|
695
700
|
*/
|
|
701
|
+
/** `plantTokens: false` defers bearer installation to a validated provider commit. */
|
|
696
702
|
async exchangeCode(params: {
|
|
697
703
|
code: string;
|
|
698
704
|
clientId: string;
|
|
699
705
|
redirectUri: string;
|
|
700
706
|
codeVerifier: string;
|
|
701
|
-
}): Promise<OAuthTokenExchangeResult> {
|
|
707
|
+
}, options: { plantTokens?: boolean } = {}): Promise<OAuthTokenExchangeResult> {
|
|
702
708
|
const form = new URLSearchParams({
|
|
703
709
|
grant_type: 'authorization_code',
|
|
704
710
|
code: params.code,
|
|
@@ -735,7 +741,9 @@ export class AuthOAuthApi {
|
|
|
735
741
|
const expiresInSec =
|
|
736
742
|
typeof record.expires_in === 'number' ? record.expires_in : DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
|
|
737
743
|
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
|
738
|
-
|
|
744
|
+
// Provider completion validates the expected subject before committing.
|
|
745
|
+
// Direct callers retain the historical token-planting default.
|
|
746
|
+
if (accessToken && options.plantTokens !== false) {
|
|
739
747
|
this.ctx.oxy.session.setAccessToken(accessToken);
|
|
740
748
|
}
|
|
741
749
|
if (!deviceId || !deviceSecret) {
|
|
@@ -1158,6 +1166,25 @@ export class AuthCommonsApi {
|
|
|
1158
1166
|
|
|
1159
1167
|
}
|
|
1160
1168
|
|
|
1169
|
+
/** Bot entry uses caller-owned keys and the normal account session lane. */
|
|
1170
|
+
export class AuthAgentApi {
|
|
1171
|
+
constructor(private readonly ctx: OxyContext) {}
|
|
1172
|
+
|
|
1173
|
+
async requestChallenge(publicKey: string): Promise<AgentProofClaims> {
|
|
1174
|
+
const body = agentChallengeRequestSchema.parse({ publicKey });
|
|
1175
|
+
const response = await this.ctx.request<unknown>('POST', '/auth/agent/challenge', body,
|
|
1176
|
+
{ cache: false, skipAuth: true });
|
|
1177
|
+
return agentProofClaimsSchema.parse(response);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async verify(publicKey: string, proof: AgentSignature): Promise<LoginResult> {
|
|
1181
|
+
const body = agentVerifyRequestSchema.parse({ publicKey, ...proof });
|
|
1182
|
+
const response = await this.ctx.request<unknown>('POST', '/auth/agent/verify', body,
|
|
1183
|
+
{ cache: false, skipAuth: true });
|
|
1184
|
+
return plantSession(this.ctx, response, 'auth/agent/verify');
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1161
1188
|
export class AuthApi {
|
|
1162
1189
|
/** Email sign-in and email confirmation codes. */
|
|
1163
1190
|
readonly email: AuthEmailApi;
|
|
@@ -1169,6 +1196,7 @@ export class AuthApi {
|
|
|
1169
1196
|
readonly oauth: AuthOAuthApi;
|
|
1170
1197
|
/** "Sign in with Oxy" handoff to Commons. */
|
|
1171
1198
|
readonly commons: AuthCommonsApi;
|
|
1199
|
+
readonly agent: AuthAgentApi;
|
|
1172
1200
|
|
|
1173
1201
|
constructor(private readonly ctx: OxyContext) {
|
|
1174
1202
|
this.email = new AuthEmailApi(ctx);
|
|
@@ -1176,6 +1204,7 @@ export class AuthApi {
|
|
|
1176
1204
|
this.totp = new AuthTotpApi(ctx);
|
|
1177
1205
|
this.oauth = new AuthOAuthApi(ctx);
|
|
1178
1206
|
this.commons = new AuthCommonsApi(ctx);
|
|
1207
|
+
this.agent = new AuthAgentApi(ctx);
|
|
1179
1208
|
}
|
|
1180
1209
|
|
|
1181
1210
|
/**
|