@oxy.so/core 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/HttpService.js +142 -33
- package/dist/cjs/api/accounts.js +49 -10
- package/dist/cjs/api/apps.js +2 -2
- package/dist/cjs/api/auth.js +24 -3
- package/dist/cjs/api/billing.js +35 -0
- package/dist/cjs/boot/sessionColdBoot.js +28 -57
- package/dist/cjs/inference/OxyInferenceClient.js +23 -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 +21 -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 +9 -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 +19 -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
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
* ESM-safe (no `require()`).
|
|
18
18
|
*/
|
|
19
19
|
import { logger } from '../logger/index.js';
|
|
20
|
+
export function matchesAuthState(actual, expected) {
|
|
21
|
+
if (!actual || !expected)
|
|
22
|
+
return actual === expected;
|
|
23
|
+
return actual.deviceId === expected.deviceId && actual.deviceSecret === expected.deviceSecret &&
|
|
24
|
+
actual.sessionId === expected.sessionId && actual.userId === expected.userId &&
|
|
25
|
+
actual.accessToken === expected.accessToken && actual.expiresAt === expected.expiresAt;
|
|
26
|
+
}
|
|
20
27
|
/**
|
|
21
28
|
* Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
|
|
22
29
|
* (`sessionId`, `userId`, `deviceId`, `deviceSecret`) — never the large JWT
|
|
@@ -35,6 +42,7 @@ import { logger } from '../logger/index.js';
|
|
|
35
42
|
* so upgrading users are not signed out; the next `save()` splits them apart.
|
|
36
43
|
*/
|
|
37
44
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
45
|
+
export const AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY = 'oxy.auth.automatic-identity-signin-suppressed.v1';
|
|
38
46
|
/**
|
|
39
47
|
* Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
|
|
40
48
|
* `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
|
|
@@ -198,6 +206,12 @@ export function createMemoryAuthStateStore() {
|
|
|
198
206
|
current = state;
|
|
199
207
|
return true;
|
|
200
208
|
},
|
|
209
|
+
saveIfCurrent: async (state, guard) => {
|
|
210
|
+
if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState))
|
|
211
|
+
return false;
|
|
212
|
+
current = state;
|
|
213
|
+
return true;
|
|
214
|
+
},
|
|
201
215
|
clear: async () => {
|
|
202
216
|
current = null;
|
|
203
217
|
},
|
|
@@ -325,8 +339,110 @@ export function createNativeAuthStateStore(storage) {
|
|
|
325
339
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
326
340
|
// must not silently lose the session for the app's lifetime.
|
|
327
341
|
let sessionMirror;
|
|
342
|
+
let suppressedMirror;
|
|
343
|
+
let pending = Promise.resolve();
|
|
344
|
+
const serialize = (operation) => {
|
|
345
|
+
const result = pending.then(operation);
|
|
346
|
+
pending = result.then(() => undefined, () => undefined);
|
|
347
|
+
return result;
|
|
348
|
+
};
|
|
349
|
+
const readCurrent = async () => {
|
|
350
|
+
if (sessionMirror !== undefined)
|
|
351
|
+
return sessionMirror;
|
|
352
|
+
const [durable, warm] = await Promise.all([
|
|
353
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
354
|
+
]);
|
|
355
|
+
return composeState(durable, warm);
|
|
356
|
+
};
|
|
357
|
+
const persistState = async (state) => {
|
|
358
|
+
sessionMirror = state;
|
|
359
|
+
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
360
|
+
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
361
|
+
// only reliable proof. The mirror keeps the session live for this app run,
|
|
362
|
+
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
363
|
+
// cold restart — surface it, never swallow.
|
|
364
|
+
let durablePersisted = false;
|
|
365
|
+
try {
|
|
366
|
+
const durableJson = serializeDurable(state);
|
|
367
|
+
await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
|
|
368
|
+
durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
|
|
369
|
+
if (!durablePersisted) {
|
|
370
|
+
logger.error('[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart', undefined, { component: 'authStateStore' });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
logger.error('[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart', error, { component: 'authStateStore' });
|
|
375
|
+
}
|
|
376
|
+
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
377
|
+
// durable credential re-mints a fresh token) and must never abort or
|
|
378
|
+
// corrupt the durable write above.
|
|
379
|
+
try {
|
|
380
|
+
const warmJson = serializeWarmToken(state);
|
|
381
|
+
if (warmJson) {
|
|
382
|
+
await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Locked / oversize keychain — non-fatal warm-boot loss only.
|
|
390
|
+
}
|
|
391
|
+
// Report ONLY the durable-credential landing; the warm-token outcome above
|
|
392
|
+
// is intentionally excluded (it is a best-effort optimization).
|
|
393
|
+
return durablePersisted;
|
|
394
|
+
};
|
|
328
395
|
return {
|
|
329
|
-
|
|
396
|
+
isAutomaticIdentitySignInSuppressed: () => serialize(async () => {
|
|
397
|
+
if (suppressedMirror !== undefined)
|
|
398
|
+
return suppressedMirror;
|
|
399
|
+
try {
|
|
400
|
+
return (await storage.getItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY)) !== null;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return true; // Unknown storage must not start a new key sign-in.
|
|
404
|
+
}
|
|
405
|
+
}),
|
|
406
|
+
setAutomaticIdentitySignInSuppressed: (suppressed, guard) => serialize(async () => {
|
|
407
|
+
if (guard) {
|
|
408
|
+
let current = sessionMirror;
|
|
409
|
+
if (current === undefined) {
|
|
410
|
+
try {
|
|
411
|
+
const [durable, warm] = await Promise.all([
|
|
412
|
+
storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
|
|
413
|
+
]);
|
|
414
|
+
current = composeState(durable, warm);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
suppressedMirror = true;
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const expected = guard.expectedState;
|
|
422
|
+
if (!guard.isCurrent() || !current ||
|
|
423
|
+
current.deviceId !== expected.deviceId || current.deviceSecret !== expected.deviceSecret ||
|
|
424
|
+
current.sessionId !== expected.sessionId || current.userId !== expected.userId ||
|
|
425
|
+
current.accessToken !== expected.accessToken || current.expiresAt !== expected.expiresAt)
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
suppressedMirror = suppressed;
|
|
429
|
+
try {
|
|
430
|
+
if (suppressed)
|
|
431
|
+
await storage.setItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY, '1');
|
|
432
|
+
else
|
|
433
|
+
await storage.removeItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
|
|
434
|
+
const actual = await storage.getItem(AUTOMATIC_IDENTITY_SIGNIN_SUPPRESSED_KEY);
|
|
435
|
+
const verified = suppressed ? actual === '1' : actual === null;
|
|
436
|
+
if (!verified)
|
|
437
|
+
suppressedMirror = true;
|
|
438
|
+
return verified;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
suppressedMirror = true;
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}),
|
|
445
|
+
load: () => serialize(async () => {
|
|
330
446
|
if (sessionMirror !== undefined) {
|
|
331
447
|
return sessionMirror;
|
|
332
448
|
}
|
|
@@ -340,46 +456,20 @@ export function createNativeAuthStateStore(storage) {
|
|
|
340
456
|
catch {
|
|
341
457
|
return null;
|
|
342
458
|
}
|
|
343
|
-
},
|
|
344
|
-
save:
|
|
345
|
-
|
|
346
|
-
// Durable credential FIRST, then VERIFY. On Android SecureStore an
|
|
347
|
-
// oversize/failed write can resolve WITHOUT throwing, so a read-back is the
|
|
348
|
-
// only reliable proof. The mirror keeps the session live for this app run,
|
|
349
|
-
// but a failed DURABLE write means the mint credential will NOT survive a
|
|
350
|
-
// cold restart — surface it, never swallow.
|
|
351
|
-
let durablePersisted = false;
|
|
459
|
+
}),
|
|
460
|
+
save: (state) => serialize(() => persistState(state)),
|
|
461
|
+
saveIfCurrent: (state, guard) => serialize(async () => {
|
|
352
462
|
try {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (!durablePersisted) {
|
|
357
|
-
logger.error('[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart', undefined, { component: 'authStateStore' });
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
catch (error) {
|
|
361
|
-
logger.error('[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart', error, { component: 'authStateStore' });
|
|
362
|
-
}
|
|
363
|
-
// Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
|
|
364
|
-
// durable credential re-mints a fresh token) and must never abort or
|
|
365
|
-
// corrupt the durable write above.
|
|
366
|
-
try {
|
|
367
|
-
const warmJson = serializeWarmToken(state);
|
|
368
|
-
if (warmJson) {
|
|
369
|
-
await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
|
|
373
|
-
}
|
|
463
|
+
const current = await readCurrent();
|
|
464
|
+
if (!guard.isCurrent() || !matchesAuthState(current, guard.expectedState))
|
|
465
|
+
return false;
|
|
374
466
|
}
|
|
375
467
|
catch {
|
|
376
|
-
|
|
468
|
+
return false;
|
|
377
469
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
},
|
|
382
|
-
clear: async () => {
|
|
470
|
+
return persistState(state);
|
|
471
|
+
}),
|
|
472
|
+
clear: () => serialize(async () => {
|
|
383
473
|
sessionMirror = null;
|
|
384
474
|
try {
|
|
385
475
|
await storage.removeItem(AUTH_STATE_STORAGE_KEY);
|
|
@@ -393,6 +483,6 @@ export function createNativeAuthStateStore(storage) {
|
|
|
393
483
|
catch {
|
|
394
484
|
// Non-fatal.
|
|
395
485
|
}
|
|
396
|
-
},
|
|
486
|
+
}),
|
|
397
487
|
};
|
|
398
488
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesAuthState } from './authStateStore.js';
|
|
1
2
|
import { establishIdentitySession, resolveIdentityPin } from './identitySession.js';
|
|
2
3
|
import { isNative } from '../utils/platform.js';
|
|
3
4
|
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
@@ -57,9 +58,13 @@ export const REMINT_AFTER_EXPIRY_MS = 2000;
|
|
|
57
58
|
*
|
|
58
59
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
59
60
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
60
|
-
* planting.
|
|
61
|
+
* planting. Apart from the account-mode logout marker below, failure mutations
|
|
62
|
+
* belong to the caller, which
|
|
61
63
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
62
|
-
* status.
|
|
64
|
+
* status. A native account store with a suppression marker records a current
|
|
65
|
+
* `no_active_session`, or an invalid secret with prior authenticated session
|
|
66
|
+
* history, records durable ACCOUNT logout intent; it never deletes that holder.
|
|
67
|
+
* Error classification uses the existing 401 message markers.
|
|
63
68
|
*
|
|
64
69
|
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
65
70
|
* `accountId` (so the server mints that account's token without touching
|
|
@@ -70,7 +75,10 @@ export const REMINT_AFTER_EXPIRY_MS = 2000;
|
|
|
70
75
|
export async function refreshDeviceSecretArm(deps) {
|
|
71
76
|
const { oxy, store } = deps;
|
|
72
77
|
const pin = deps.pin ?? null;
|
|
78
|
+
const isCurrent = deps.isCurrent ?? (() => true);
|
|
73
79
|
return oxy.http.runSingleFlightDeviceSecretMint(async () => {
|
|
80
|
+
if (!isCurrent())
|
|
81
|
+
return { status: 'session-ended' };
|
|
74
82
|
const epoch = oxy.http.getSessionEpoch();
|
|
75
83
|
const persisted = await store.load();
|
|
76
84
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
@@ -112,10 +120,29 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
112
120
|
// transient and KEEP the credential so a later attempt self-heals. Wiping the
|
|
113
121
|
// credential on an ambiguous 401 is what logged users out on every deploy,
|
|
114
122
|
// ecosystem-wide.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
123
|
+
const invalidSecret = body.includes('invalid_device_secret');
|
|
124
|
+
const noSession = body.includes('no_active_session');
|
|
125
|
+
if (invalidSecret || noSession) {
|
|
126
|
+
// A stopped ACCOUNT sibling cannot receive the other app's logout
|
|
127
|
+
// push. Its exact live holder's authoritative empty-device rejection
|
|
128
|
+
// must suppress automatic key recovery on this and future boots.
|
|
129
|
+
// A late rejection for a replaced session/local epoch is not current.
|
|
130
|
+
if (pin === null && store.setAutomaticIdentitySignInSuppressed && (!invalidSecret || (persisted.sessionId && persisted.userId))) {
|
|
131
|
+
const current = await store.load();
|
|
132
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch ||
|
|
133
|
+
current?.deviceId !== persisted.deviceId ||
|
|
134
|
+
current.deviceSecret !== persisted.deviceSecret ||
|
|
135
|
+
current.sessionId !== persisted.sessionId ||
|
|
136
|
+
current.userId !== persisted.userId ||
|
|
137
|
+
current.accessToken !== persisted.accessToken)
|
|
138
|
+
return { status: 'session-ended' };
|
|
139
|
+
if (!(await store.setAutomaticIdentitySignInSuppressed(true, {
|
|
140
|
+
expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch,
|
|
141
|
+
})))
|
|
142
|
+
return { status: 'session-ended' };
|
|
143
|
+
}
|
|
144
|
+
return { status: invalidSecret ? 'invalid-secret' : 'no-session' };
|
|
145
|
+
}
|
|
119
146
|
// A pinned mint whose account left the device set. The secret is intact —
|
|
120
147
|
// never classify this as a bad secret, or the caller would drop a healthy
|
|
121
148
|
// credential over a stale identity binding.
|
|
@@ -137,24 +164,33 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
137
164
|
expiresAt: mint.expiresAt,
|
|
138
165
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
139
166
|
};
|
|
167
|
+
if (!isCurrent())
|
|
168
|
+
return { status: 'session-ended' };
|
|
140
169
|
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
170
|
+
// A logout or account/session switch invalidated this mint. Preserve
|
|
171
|
+
// only credential rotation for an otherwise unchanged retained context;
|
|
172
|
+
// never transplant the old mint's account, session or bearer. Compare
|
|
173
|
+
// the entire snapshot inside the store queue: same holder bytes alone
|
|
174
|
+
// cannot distinguish an account switch, nor a queued save of that switch.
|
|
175
|
+
const retainedEpoch = oxy.http.getSessionEpoch();
|
|
145
176
|
const current = await store.load();
|
|
146
|
-
if (
|
|
147
|
-
await store.
|
|
177
|
+
if (store.saveIfCurrent && current && matchesAuthState(current, persisted)) {
|
|
178
|
+
await store.saveIfCurrent({ ...current, deviceSecret: mint.nextDeviceSecret }, {
|
|
179
|
+
expectedState: current,
|
|
180
|
+
isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === retainedEpoch,
|
|
181
|
+
});
|
|
148
182
|
}
|
|
149
183
|
return { status: 'session-ended' };
|
|
150
184
|
}
|
|
151
185
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
152
186
|
// A failed durable persist must NOT plant.
|
|
153
|
-
const persistedOk =
|
|
187
|
+
const persistedOk = store.saveIfCurrent
|
|
188
|
+
? await store.saveIfCurrent(next, { expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch })
|
|
189
|
+
: await store.save(next);
|
|
154
190
|
if (!persistedOk) {
|
|
155
191
|
return { status: 'persist-failed' };
|
|
156
192
|
}
|
|
157
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
193
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) {
|
|
158
194
|
return { status: 'session-ended' };
|
|
159
195
|
}
|
|
160
196
|
oxy.session.setAccessToken(mint.accessToken);
|
|
@@ -167,15 +203,76 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
167
203
|
};
|
|
168
204
|
});
|
|
169
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Prove a shared native ACCOUNT holder before replacing local bytes. Callers
|
|
208
|
+
* may replace a local credential only after its explicit mint rejection. The
|
|
209
|
+
* optional guarded storage seam makes custom stores fail closed on adoption.
|
|
210
|
+
* This lane never clears explicit sign-out intent or uses an identity key.
|
|
211
|
+
*/
|
|
212
|
+
export async function refreshSharedDeviceArm(deps) {
|
|
213
|
+
const { oxy, store, shared } = deps;
|
|
214
|
+
const isCurrent = deps.isCurrent ?? (() => true);
|
|
215
|
+
return oxy.http.runSingleFlightDeviceSecretMint(async () => {
|
|
216
|
+
const epoch = oxy.http.getSessionEpoch();
|
|
217
|
+
if (!isCurrent())
|
|
218
|
+
return { status: 'session-ended' };
|
|
219
|
+
const before = await store.load();
|
|
220
|
+
if (before?.deviceId && before.deviceSecret && !deps.rejectedLocalHolder)
|
|
221
|
+
return { status: 'no-secret' };
|
|
222
|
+
const read = await shared.read();
|
|
223
|
+
if (read.state !== 'present' || !store.saveIfCurrent)
|
|
224
|
+
return { status: 'no-secret' };
|
|
225
|
+
const credential = read.credential;
|
|
226
|
+
if (credential.deviceId === before?.deviceId && credential.deviceSecret === before.deviceSecret)
|
|
227
|
+
return { status: 'no-secret' };
|
|
228
|
+
let mint;
|
|
229
|
+
try {
|
|
230
|
+
mint = await oxy.devices.mintToken(credential.deviceId, credential.deviceSecret);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (extractErrorStatus(error) === 429)
|
|
234
|
+
oxy.http.noteRefreshRateLimited();
|
|
235
|
+
if (extractErrorStatus(error) === 401) {
|
|
236
|
+
const message = error?.message;
|
|
237
|
+
if (typeof message === 'string' && message.includes('invalid_device_secret')) {
|
|
238
|
+
// There is no cross-app CAS clear in the bridge contract. An old
|
|
239
|
+
// rejection cannot delete a slot another app has since published.
|
|
240
|
+
return { status: 'invalid-secret' };
|
|
241
|
+
}
|
|
242
|
+
if (typeof message === 'string' && message.includes('no_active_session'))
|
|
243
|
+
return { status: 'no-session' };
|
|
244
|
+
}
|
|
245
|
+
return { status: 'transient' };
|
|
246
|
+
}
|
|
247
|
+
const bound = mint.state.accounts.find(account => account.accountId === mint.state.activeAccountId);
|
|
248
|
+
if (!bound || mint.state.deviceId !== credential.deviceId || !mint.accessToken || !mint.nextDeviceSecret)
|
|
249
|
+
return { status: 'transient' };
|
|
250
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch || !matchesAuthState(await store.load(), before))
|
|
251
|
+
return { status: 'session-ended' };
|
|
252
|
+
const next = {
|
|
253
|
+
deviceId: mint.state.deviceId, deviceSecret: mint.nextDeviceSecret,
|
|
254
|
+
sessionId: bound.sessionId, userId: bound.accountId,
|
|
255
|
+
accessToken: mint.accessToken, expiresAt: mint.expiresAt,
|
|
256
|
+
};
|
|
257
|
+
if (!(await store.saveIfCurrent(next, { expectedState: before, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch })))
|
|
258
|
+
return { status: 'persist-failed' };
|
|
259
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch)
|
|
260
|
+
return { status: 'session-ended' };
|
|
261
|
+
oxy.session.setAccessToken(mint.accessToken);
|
|
262
|
+
return { status: 'ok', token: mint.accessToken, sessionId: bound.sessionId, userId: bound.accountId, state: mint.state };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
170
265
|
/**
|
|
171
266
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
172
267
|
* when no arm could produce one.
|
|
173
268
|
*
|
|
174
269
|
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
175
|
-
* from the persisted `deviceId` + `deviceSecret`.
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
270
|
+
* from the persisted `deviceId` + `deviceSecret`. An authenticated native
|
|
271
|
+
* holder's invalid-secret or `no_active_session` verdict suppresses key recovery
|
|
272
|
+
* while permitting a separately proved new shared holder. The latter verdict
|
|
273
|
+
* retains that holder and ends ACCOUNT recovery without a new key challenge.
|
|
274
|
+
* Classification still uses the existing 401 message markers, not a new parser.
|
|
275
|
+
* A transient error — or a durable-persist failure — leaves the store and
|
|
179
276
|
* returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
|
|
180
277
|
*
|
|
181
278
|
* Arm 2 (native Commons identity): when the secret is absent or was just
|
|
@@ -192,9 +289,11 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
192
289
|
*/
|
|
193
290
|
export async function refreshPersistedSession(deps) {
|
|
194
291
|
const { oxy, store } = deps;
|
|
292
|
+
if (deps.isCurrent && !deps.isCurrent())
|
|
293
|
+
return null;
|
|
195
294
|
const identity = deps.identity ?? null;
|
|
196
295
|
// The shared identity is never an identity-bound client's recovery path.
|
|
197
|
-
const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? isNative());
|
|
296
|
+
const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? isNative()) && !(await store.isAutomaticIdentitySignInSuppressed?.());
|
|
198
297
|
const epoch = oxy.http.getSessionEpoch();
|
|
199
298
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
200
299
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
@@ -203,7 +302,7 @@ export async function refreshPersistedSession(deps) {
|
|
|
203
302
|
if (identity && !pin) {
|
|
204
303
|
return recoverIdentitySession(oxy, store, identity);
|
|
205
304
|
}
|
|
206
|
-
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin });
|
|
305
|
+
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin, isCurrent: deps.isCurrent });
|
|
207
306
|
switch (arm1.status) {
|
|
208
307
|
case 'ok':
|
|
209
308
|
return arm1.token;
|
|
@@ -221,6 +320,28 @@ export async function refreshPersistedSession(deps) {
|
|
|
221
320
|
return null;
|
|
222
321
|
case 'invalid-secret':
|
|
223
322
|
case 'no-session': {
|
|
323
|
+
if (!identity && deps.sharedDeviceCredential) {
|
|
324
|
+
const joined = await refreshSharedDeviceArm({ oxy, store, shared: deps.sharedDeviceCredential, rejectedLocalHolder: true, isCurrent: deps.isCurrent });
|
|
325
|
+
if (joined.status === 'ok')
|
|
326
|
+
return joined.token;
|
|
327
|
+
if (joined.status === 'session-ended' || joined.status === 'persist-failed')
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
if (!identity && arm1.status === 'invalid-secret' && await store.isAutomaticIdentitySignInSuppressed?.())
|
|
331
|
+
return null;
|
|
332
|
+
if (arm1.status === 'no-session' && !identity) {
|
|
333
|
+
if (store.setAutomaticIdentitySignInSuppressed)
|
|
334
|
+
return null;
|
|
335
|
+
// The holder is still valid, but its account session ended. Retain only
|
|
336
|
+
// the holder for a later deliberate/shared sign-in; never use the key
|
|
337
|
+
// to turn this signed-out verdict into a new account session.
|
|
338
|
+
const persisted = await store.load();
|
|
339
|
+
if (persisted?.deviceId && persisted.deviceSecret) {
|
|
340
|
+
await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
// Identity mode retains its separate pinned-key recovery contract.
|
|
224
345
|
// 401: secret diverged or no live session. When a key-based arm 2 can still
|
|
225
346
|
// recover (the native Commons identity, or an identity-bound client's own
|
|
226
347
|
// primary key) drop ONLY the secret and keep the deviceId; otherwise (web) the
|
|
@@ -231,13 +352,6 @@ export async function refreshPersistedSession(deps) {
|
|
|
231
352
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
232
353
|
}
|
|
233
354
|
}
|
|
234
|
-
else if (arm1.status === 'no-session' && persisted?.deviceId && persisted.deviceSecret) {
|
|
235
|
-
// Web, a credential the server still recognises: this origin stays a
|
|
236
|
-
// holder of the browser's device (ADR 0029 D2) — the next sign-in in any
|
|
237
|
-
// app lands on it, and this app follows without opening the bridge
|
|
238
|
-
// again. Only the session fields go.
|
|
239
|
-
await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
|
|
240
|
-
}
|
|
241
355
|
else {
|
|
242
356
|
await store.clear();
|
|
243
357
|
}
|
|
@@ -249,6 +363,13 @@ export async function refreshPersistedSession(deps) {
|
|
|
249
363
|
logger.debug('Pinned device-secret mint rejected: the pinned account is no longer on this device — re-establishing from the identity key', { component: 'refresh', method: 'refreshPersistedSession' });
|
|
250
364
|
break;
|
|
251
365
|
case 'no-secret':
|
|
366
|
+
if (!identity && deps.sharedDeviceCredential) {
|
|
367
|
+
const joined = await refreshSharedDeviceArm({ oxy, store, shared: deps.sharedDeviceCredential, isCurrent: deps.isCurrent });
|
|
368
|
+
if (joined.status === 'ok')
|
|
369
|
+
return joined.token;
|
|
370
|
+
if (joined.status === 'session-ended' || joined.status === 'persist-failed' || joined.status === 'transient')
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
252
373
|
break;
|
|
253
374
|
}
|
|
254
375
|
if (identity) {
|
|
@@ -256,14 +377,14 @@ export async function refreshPersistedSession(deps) {
|
|
|
256
377
|
}
|
|
257
378
|
// Never after a sign-out: the shared identity is a KEY, not a session, and
|
|
258
379
|
// proving it here would sign the user straight back in.
|
|
259
|
-
if (allowCommonsIdentityFallback && !oxy.http.hasSessionEnded()) {
|
|
380
|
+
if (allowCommonsIdentityFallback && (deps.isCurrent?.() ?? true) && !oxy.http.hasSessionEnded() && !(await store.isAutomaticIdentitySignInSuppressed?.())) {
|
|
260
381
|
try {
|
|
261
382
|
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
262
383
|
// challenge round-trips must win, or the identity proof signs the user
|
|
263
384
|
// straight back in.
|
|
264
385
|
const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false });
|
|
265
386
|
if (session?.accessToken) {
|
|
266
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
387
|
+
if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
267
388
|
return null;
|
|
268
389
|
}
|
|
269
390
|
// Repopulate the fast device-secret lane from the identity re-mint.
|
|
@@ -277,7 +398,7 @@ export async function refreshPersistedSession(deps) {
|
|
|
277
398
|
expiresAt: session.expiresAt,
|
|
278
399
|
});
|
|
279
400
|
}
|
|
280
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
401
|
+
if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
281
402
|
return null;
|
|
282
403
|
}
|
|
283
404
|
oxy.session.setAccessToken(session.accessToken);
|
|
@@ -193,7 +193,9 @@ export async function publishProvenDeviceCredential(deps) {
|
|
|
193
193
|
* seam, no lane left out, and no new call site to forget.
|
|
194
194
|
* - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
|
|
195
195
|
* a storage primitive, and would run on every `load()`. Adoption is an
|
|
196
|
-
* explicit
|
|
196
|
+
* explicit cold-boot/resume recovery instead; an existing local holder must
|
|
197
|
+
* first be rejected by the server, and a new shared holder must mint before
|
|
198
|
+
* conditional persistence.
|
|
197
199
|
*
|
|
198
200
|
* `clear()` deliberately does NOT clear the shared slot. This app signing out is
|
|
199
201
|
* not authority over the device-wide join point: other apps may still be signed
|
|
@@ -208,8 +210,22 @@ export function createSharedMirroringAuthStateStore(deps) {
|
|
|
208
210
|
// next launch rather than mid-process.
|
|
209
211
|
let mirrored = null;
|
|
210
212
|
return {
|
|
213
|
+
...local,
|
|
211
214
|
load: () => local.load(),
|
|
212
215
|
clear: () => local.clear(),
|
|
216
|
+
...(local.saveIfCurrent ? { saveIfCurrent: async (state, guard) => {
|
|
217
|
+
const committed = await local.saveIfCurrent?.(state, guard);
|
|
218
|
+
if (!committed)
|
|
219
|
+
return false;
|
|
220
|
+
const credential = readLocalDeviceCredential(state);
|
|
221
|
+
if (credential && guard.isCurrent()) {
|
|
222
|
+
try {
|
|
223
|
+
await publishProvenDeviceCredential({ shared, credential });
|
|
224
|
+
}
|
|
225
|
+
catch { /* Local durability already verified. */ }
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
} } : {}),
|
|
213
229
|
save: async (state) => {
|
|
214
230
|
// The durable local write is the contract this store owes its caller —
|
|
215
231
|
// run it first and report ITS result, unchanged. The mirror is additive.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isLoopbackOrigin } from '@oxy.so/contracts';
|
|
2
|
+
export { isLoopbackOrigin } from '@oxy.so/contracts';
|
|
1
3
|
/**
|
|
2
4
|
* Official first-party web origin allowlist — shared by OAuth redirect
|
|
3
5
|
* validation and the server-side trusted-origin checks.
|
|
@@ -21,19 +23,6 @@ const OFFICIAL_APEXES = new Set([
|
|
|
21
23
|
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
22
24
|
* or `[::1]` on any port, http or https).
|
|
23
25
|
*/
|
|
24
|
-
export function isLoopbackOrigin(origin) {
|
|
25
|
-
try {
|
|
26
|
-
const parsed = new URL(origin);
|
|
27
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
const host = parsed.hostname.toLowerCase();
|
|
31
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
26
|
/** Whether an origin belongs to the official Oxy web ecosystem. */
|
|
38
27
|
export function isOfficialWebOrigin(origin) {
|
|
39
28
|
if (isLoopbackOrigin(origin)) {
|
|
@@ -22,19 +22,25 @@ export type AccessTokenProvider = () => string | null;
|
|
|
22
22
|
* — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
|
|
23
23
|
*/
|
|
24
24
|
export type AnonymousAuthProvider = () => Promise<string | null>;
|
|
25
|
+
/** A trusted platform transport, for example Expo's streaming fetch. */
|
|
26
|
+
export type ResponseTransport = (input: string, init: RequestInit) => Promise<Response>;
|
|
25
27
|
/**
|
|
26
|
-
* A low-level
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* A low-level same-origin request whose response body remains unread.
|
|
29
|
+
* Only absent/string request bodies can be replayed after a 401. Multipart,
|
|
30
|
+
* binary and stream bodies are sent once, even if their platform can replay them.
|
|
31
|
+
* The caller owns response parsing and the AbortSignal for its full lifetime.
|
|
30
32
|
*/
|
|
31
|
-
export interface
|
|
33
|
+
export interface ResponseRequest {
|
|
32
34
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
33
35
|
url: string;
|
|
34
|
-
body?:
|
|
35
|
-
headers?:
|
|
36
|
+
body?: BodyInit;
|
|
37
|
+
headers?: HeadersInit;
|
|
36
38
|
signal?: AbortSignal;
|
|
39
|
+
/** Must honour the supplied URL, headers, signal and redirect policy. */
|
|
40
|
+
fetch?: ResponseTransport;
|
|
37
41
|
}
|
|
42
|
+
/** Same response contract, but rejects locally when the SDK has no bearer. */
|
|
43
|
+
export type AuthenticatedResponseRequest = ResponseRequest;
|
|
38
44
|
export interface RequestOptions {
|
|
39
45
|
cache?: boolean;
|
|
40
46
|
cacheTTL?: number;
|
|
@@ -431,7 +437,14 @@ export declare class HttpService {
|
|
|
431
437
|
* abort signal defines the lifetime of a healthy stream.
|
|
432
438
|
*/
|
|
433
439
|
requestAuthenticatedResponse(config: AuthenticatedResponseRequest): Promise<Response>;
|
|
434
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Raw response for public or authenticated app endpoints. The SDK supplies
|
|
442
|
+
* its current bearer when available; caller headers never supply authority.
|
|
443
|
+
* No caching, envelope transformation or transport-error retry is performed.
|
|
444
|
+
*/
|
|
445
|
+
requestResponse(config: ResponseRequest): Promise<Response>;
|
|
446
|
+
private requestRawResponse;
|
|
447
|
+
private requestResponseAttempt;
|
|
435
448
|
get<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<T>;
|
|
436
449
|
post<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<T>;
|
|
437
450
|
put<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<T>;
|
|
@@ -442,13 +455,14 @@ export declare class HttpService {
|
|
|
442
455
|
* End the local session: clear the bearer and abandon every re-mint already
|
|
443
456
|
* in flight, so none of them can plant a token after the sign-out.
|
|
444
457
|
*
|
|
445
|
-
* {@link clearTokens}
|
|
446
|
-
*
|
|
447
|
-
* refresh started a moment earlier would finish and plant a new one.
|
|
458
|
+
* Unlike {@link clearTokens}, this also marks the session as ended and
|
|
459
|
+
* invalidates pending re-mints even when no bearer is currently present.
|
|
448
460
|
*/
|
|
449
461
|
endSession(): void;
|
|
450
462
|
/**
|
|
451
|
-
* The current
|
|
463
|
+
* The current context epoch: account/session switches and bearer removal
|
|
464
|
+
* invalidate pending work; rotation within the same context preserves it.
|
|
465
|
+
* A re-mint lane that
|
|
452
466
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
453
467
|
*/
|
|
454
468
|
getSessionEpoch(): number;
|