@oxy.so/core 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/HttpService.js +142 -33
- package/dist/cjs/api/accounts.js +49 -10
- package/dist/cjs/api/apps.js +2 -2
- package/dist/cjs/api/auth.js +24 -3
- package/dist/cjs/api/billing.js +35 -0
- package/dist/cjs/boot/sessionColdBoot.js +28 -57
- package/dist/cjs/inference/OxyInferenceClient.js +34 -15
- package/dist/cjs/server/OxyServer.js +107 -51
- package/dist/cjs/server/agentAccount.js +19 -0
- package/dist/cjs/server/auth.js +19 -0
- package/dist/cjs/server/capabilityTicket.js +3 -41
- package/dist/cjs/server/index.js +10 -2
- package/dist/cjs/server/liveCapabilityTicket.js +48 -0
- package/dist/cjs/server/middleware.js +60 -18
- package/dist/cjs/server/namespaces.js +57 -0
- package/dist/cjs/server/serviceTokenLifetime.js +13 -0
- package/dist/cjs/session/SessionClient.js +90 -12
- package/dist/cjs/session/authStateStore.js +130 -39
- package/dist/cjs/session/refresh.js +152 -30
- package/dist/cjs/session/sharedDeviceCredential.js +17 -1
- package/dist/cjs/utils/officialOrigins.js +7 -18
- package/dist/esm/HttpService.js +143 -34
- package/dist/esm/api/accounts.js +38 -0
- package/dist/esm/api/apps.js +2 -2
- package/dist/esm/api/auth.js +23 -3
- package/dist/esm/api/billing.js +35 -0
- package/dist/esm/boot/sessionColdBoot.js +29 -58
- package/dist/esm/inference/OxyInferenceClient.js +32 -13
- package/dist/esm/server/OxyServer.js +107 -51
- package/dist/esm/server/agentAccount.js +16 -0
- package/dist/esm/server/auth.js +18 -0
- package/dist/esm/server/capabilityTicket.js +1 -39
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/liveCapabilityTicket.js +45 -0
- package/dist/esm/server/middleware.js +60 -18
- package/dist/esm/server/namespaces.js +57 -0
- package/dist/esm/server/serviceTokenLifetime.js +9 -0
- package/dist/esm/session/SessionClient.js +90 -12
- package/dist/esm/session/authStateStore.js +128 -38
- package/dist/esm/session/refresh.js +151 -30
- package/dist/esm/session/sharedDeviceCredential.js +17 -1
- package/dist/esm/utils/officialOrigins.js +2 -13
- package/dist/types/HttpService.d.ts +26 -12
- package/dist/types/api/accounts.d.ts +38 -0
- package/dist/types/api/agency.d.ts +3 -1
- package/dist/types/api/apps.d.ts +3 -1
- package/dist/types/api/auth.d.ts +12 -1
- package/dist/types/api/billing.d.ts +12 -0
- package/dist/types/api/session.d.ts +3 -1
- package/dist/types/boot/sessionColdBoot.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +18 -0
- package/dist/types/inference/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +16 -9
- package/dist/types/server/agentAccount.d.ts +14 -0
- package/dist/types/server/auth.d.ts +18 -0
- package/dist/types/server/capabilityTicket.d.ts +2 -4
- package/dist/types/server/index.d.ts +7 -1
- package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
- package/dist/types/server/middleware.d.ts +15 -6
- package/dist/types/server/namespaces.d.ts +78 -2
- package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/dist/types/session/authStateStore.d.ts +15 -0
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +32 -7
- package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
- package/dist/types/utils/officialOrigins.d.ts +1 -5
- package/package.json +6 -6
- package/src/HttpService.ts +138 -50
- package/src/api/accounts.ts +22 -0
- package/src/api/agency.ts +3 -1
- package/src/api/apps.ts +2 -2
- package/src/api/auth.ts +31 -2
- package/src/api/billing.ts +71 -9
- package/src/api/session.ts +3 -1
- package/src/boot/sessionColdBoot.ts +27 -72
- package/src/index.ts +1 -1
- package/src/inference/OxyInferenceClient.ts +35 -5
- package/src/inference/index.ts +1 -0
- package/src/server/OxyServer.ts +114 -53
- package/src/server/agentAccount.ts +28 -0
- package/src/server/auth.ts +25 -0
- package/src/server/capabilityTicket.ts +2 -42
- package/src/server/index.ts +13 -0
- package/src/server/liveCapabilityTicket.ts +47 -0
- package/src/server/middleware.ts +72 -25
- package/src/server/namespaces.ts +99 -2
- package/src/server/serviceTokenLifetime.ts +13 -0
- package/src/session/SessionClient.ts +85 -12
- package/src/session/authStateStore.ts +130 -46
- package/src/session/index.ts +1 -0
- package/src/session/refresh.ts +144 -28
- package/src/session/sharedDeviceCredential.ts +14 -2
- package/src/utils/officialOrigins.ts +2 -13
package/src/session/refresh.ts
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import type { DeviceSessionState, DeviceTokenMintResponse } from '@oxy.so/contracts';
|
|
25
25
|
import type { OxyServices } from '../OxyServices';
|
|
26
26
|
import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
|
|
27
|
-
import type
|
|
27
|
+
import { matchesAuthState, type AuthStateStore, type PersistedAuthState } from './authStateStore';
|
|
28
|
+
import type { SharedDeviceCredentialStore } from './sharedDeviceCredential';
|
|
28
29
|
import type { IdentityPin } from './identityPin';
|
|
29
30
|
import { establishIdentitySession, resolveIdentityPin, type IdentityBinding } from './identitySession';
|
|
30
31
|
import { isNative } from '../utils/platform';
|
|
@@ -99,6 +100,10 @@ export interface RefreshDeps {
|
|
|
99
100
|
* Commons-identity one.
|
|
100
101
|
*/
|
|
101
102
|
identity?: IdentityBinding;
|
|
103
|
+
/** Native ACCOUNT join point. A live local holder always wins. */
|
|
104
|
+
sharedDeviceCredential?: SharedDeviceCredentialStore;
|
|
105
|
+
/** A replaced/unmounted provider cannot commit a pending refresh. */
|
|
106
|
+
isCurrent?: () => boolean;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/**
|
|
@@ -148,9 +153,13 @@ export type DeviceSecretMintOutcome =
|
|
|
148
153
|
*
|
|
149
154
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
150
155
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
151
|
-
* planting.
|
|
156
|
+
* planting. Apart from the account-mode logout marker below, failure mutations
|
|
157
|
+
* belong to the caller, which
|
|
152
158
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
153
|
-
* status.
|
|
159
|
+
* status. A native account store with a suppression marker records a current
|
|
160
|
+
* `no_active_session`, or an invalid secret with prior authenticated session
|
|
161
|
+
* history, records durable ACCOUNT logout intent; it never deletes that holder.
|
|
162
|
+
* Error classification uses the existing 401 message markers.
|
|
154
163
|
*
|
|
155
164
|
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
156
165
|
* `accountId` (so the server mints that account's token without touching
|
|
@@ -163,10 +172,13 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
163
172
|
store: AuthStateStore;
|
|
164
173
|
/** The identity pin, when this client is identity-bound. */
|
|
165
174
|
pin?: IdentityPin | null;
|
|
175
|
+
isCurrent?: () => boolean;
|
|
166
176
|
}): Promise<DeviceSecretMintOutcome> {
|
|
167
177
|
const { oxy, store } = deps;
|
|
168
178
|
const pin = deps.pin ?? null;
|
|
179
|
+
const isCurrent = deps.isCurrent ?? (() => true);
|
|
169
180
|
return oxy.http.runSingleFlightDeviceSecretMint(async () => {
|
|
181
|
+
if (!isCurrent()) return {status: 'session-ended'};
|
|
170
182
|
const epoch = oxy.http.getSessionEpoch();
|
|
171
183
|
const persisted = await store.load();
|
|
172
184
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
@@ -208,8 +220,29 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
208
220
|
// transient and KEEP the credential so a later attempt self-heals. Wiping the
|
|
209
221
|
// credential on an ambiguous 401 is what logged users out on every deploy,
|
|
210
222
|
// ecosystem-wide.
|
|
211
|
-
|
|
212
|
-
|
|
223
|
+
const invalidSecret = body.includes('invalid_device_secret');
|
|
224
|
+
const noSession = body.includes('no_active_session');
|
|
225
|
+
if (invalidSecret || noSession) {
|
|
226
|
+
// A stopped ACCOUNT sibling cannot receive the other app's logout
|
|
227
|
+
// push. Its exact live holder's authoritative empty-device rejection
|
|
228
|
+
// must suppress automatic key recovery on this and future boots.
|
|
229
|
+
// A late rejection for a replaced session/local epoch is not current.
|
|
230
|
+
if (pin === null && store.setAutomaticIdentitySignInSuppressed && (!invalidSecret || (persisted.sessionId && persisted.userId))) {
|
|
231
|
+
const current = await store.load();
|
|
232
|
+
if (
|
|
233
|
+
!isCurrent() || oxy.http.getSessionEpoch() !== epoch ||
|
|
234
|
+
current?.deviceId !== persisted.deviceId ||
|
|
235
|
+
current.deviceSecret !== persisted.deviceSecret ||
|
|
236
|
+
current.sessionId !== persisted.sessionId ||
|
|
237
|
+
current.userId !== persisted.userId ||
|
|
238
|
+
current.accessToken !== persisted.accessToken
|
|
239
|
+
) return { status: 'session-ended' };
|
|
240
|
+
if (!(await store.setAutomaticIdentitySignInSuppressed(true, {
|
|
241
|
+
expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch,
|
|
242
|
+
}))) return { status: 'session-ended' };
|
|
243
|
+
}
|
|
244
|
+
return { status: invalidSecret ? 'invalid-secret' : 'no-session' };
|
|
245
|
+
}
|
|
213
246
|
// A pinned mint whose account left the device set. The secret is intact —
|
|
214
247
|
// never classify this as a bad secret, or the caller would drop a healthy
|
|
215
248
|
// credential over a stale identity binding.
|
|
@@ -231,24 +264,32 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
231
264
|
expiresAt: mint.expiresAt,
|
|
232
265
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
233
266
|
};
|
|
267
|
+
if (!isCurrent()) return {status: 'session-ended'};
|
|
234
268
|
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
269
|
+
// A logout or account/session switch invalidated this mint. Preserve
|
|
270
|
+
// only credential rotation for an otherwise unchanged retained context;
|
|
271
|
+
// never transplant the old mint's account, session or bearer. Compare
|
|
272
|
+
// the entire snapshot inside the store queue: same holder bytes alone
|
|
273
|
+
// cannot distinguish an account switch, nor a queued save of that switch.
|
|
274
|
+
const retainedEpoch = oxy.http.getSessionEpoch();
|
|
239
275
|
const current = await store.load();
|
|
240
|
-
if (
|
|
241
|
-
await store.
|
|
276
|
+
if (store.saveIfCurrent && current && matchesAuthState(current, persisted)) {
|
|
277
|
+
await store.saveIfCurrent({...current, deviceSecret: mint.nextDeviceSecret}, {
|
|
278
|
+
expectedState: current,
|
|
279
|
+
isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === retainedEpoch,
|
|
280
|
+
});
|
|
242
281
|
}
|
|
243
282
|
return { status: 'session-ended' };
|
|
244
283
|
}
|
|
245
284
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
246
285
|
// A failed durable persist must NOT plant.
|
|
247
|
-
const persistedOk =
|
|
286
|
+
const persistedOk = store.saveIfCurrent
|
|
287
|
+
? await store.saveIfCurrent(next, {expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch})
|
|
288
|
+
: await store.save(next);
|
|
248
289
|
if (!persistedOk) {
|
|
249
290
|
return { status: 'persist-failed' };
|
|
250
291
|
}
|
|
251
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
292
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) {
|
|
252
293
|
return { status: 'session-ended' };
|
|
253
294
|
}
|
|
254
295
|
oxy.session.setAccessToken(mint.accessToken);
|
|
@@ -262,15 +303,72 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
262
303
|
});
|
|
263
304
|
}
|
|
264
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Prove a shared native ACCOUNT holder before replacing local bytes. Callers
|
|
308
|
+
* may replace a local credential only after its explicit mint rejection. The
|
|
309
|
+
* optional guarded storage seam makes custom stores fail closed on adoption.
|
|
310
|
+
* This lane never clears explicit sign-out intent or uses an identity key.
|
|
311
|
+
*/
|
|
312
|
+
export async function refreshSharedDeviceArm(deps: {
|
|
313
|
+
oxy: OxyServices;
|
|
314
|
+
store: AuthStateStore;
|
|
315
|
+
shared: SharedDeviceCredentialStore;
|
|
316
|
+
rejectedLocalHolder?: boolean;
|
|
317
|
+
isCurrent?: () => boolean;
|
|
318
|
+
}): Promise<DeviceSecretMintOutcome> {
|
|
319
|
+
const { oxy, store, shared } = deps;
|
|
320
|
+
const isCurrent = deps.isCurrent ?? (() => true);
|
|
321
|
+
return oxy.http.runSingleFlightDeviceSecretMint(async () => {
|
|
322
|
+
const epoch = oxy.http.getSessionEpoch();
|
|
323
|
+
if (!isCurrent()) return {status: 'session-ended'};
|
|
324
|
+
const before = await store.load();
|
|
325
|
+
if (before?.deviceId && before.deviceSecret && !deps.rejectedLocalHolder) return { status: 'no-secret' };
|
|
326
|
+
const read = await shared.read();
|
|
327
|
+
if (read.state !== 'present' || !store.saveIfCurrent) return { status: 'no-secret' };
|
|
328
|
+
const credential = read.credential;
|
|
329
|
+
if (credential.deviceId === before?.deviceId && credential.deviceSecret === before.deviceSecret) return { status: 'no-secret' };
|
|
330
|
+
let mint: DeviceTokenMintResponse;
|
|
331
|
+
try {
|
|
332
|
+
mint = await oxy.devices.mintToken(credential.deviceId, credential.deviceSecret);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (extractErrorStatus(error) === 429) oxy.http.noteRefreshRateLimited();
|
|
335
|
+
if (extractErrorStatus(error) === 401) {
|
|
336
|
+
const message = (error as {message?: unknown})?.message;
|
|
337
|
+
if (typeof message === 'string' && message.includes('invalid_device_secret')) {
|
|
338
|
+
// There is no cross-app CAS clear in the bridge contract. An old
|
|
339
|
+
// rejection cannot delete a slot another app has since published.
|
|
340
|
+
return {status: 'invalid-secret'};
|
|
341
|
+
}
|
|
342
|
+
if (typeof message === 'string' && message.includes('no_active_session')) return {status: 'no-session'};
|
|
343
|
+
}
|
|
344
|
+
return {status: 'transient'};
|
|
345
|
+
}
|
|
346
|
+
const bound = mint.state.accounts.find(account => account.accountId === mint.state.activeAccountId);
|
|
347
|
+
if (!bound || mint.state.deviceId !== credential.deviceId || !mint.accessToken || !mint.nextDeviceSecret) return {status: 'transient'};
|
|
348
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch || !matchesAuthState(await store.load(), before)) return {status: 'session-ended'};
|
|
349
|
+
const next: PersistedAuthState = {
|
|
350
|
+
deviceId: mint.state.deviceId, deviceSecret: mint.nextDeviceSecret,
|
|
351
|
+
sessionId: bound.sessionId, userId: bound.accountId,
|
|
352
|
+
accessToken: mint.accessToken, expiresAt: mint.expiresAt,
|
|
353
|
+
};
|
|
354
|
+
if (!(await store.saveIfCurrent(next, {expectedState: before, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch}))) return {status: 'persist-failed'};
|
|
355
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) return {status: 'session-ended'};
|
|
356
|
+
oxy.session.setAccessToken(mint.accessToken);
|
|
357
|
+
return {status: 'ok', token: mint.accessToken, sessionId: bound.sessionId, userId: bound.accountId, state: mint.state};
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
265
361
|
/**
|
|
266
362
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
267
363
|
* when no arm could produce one.
|
|
268
364
|
*
|
|
269
365
|
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
270
|
-
* from the persisted `deviceId` + `deviceSecret`.
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
366
|
+
* from the persisted `deviceId` + `deviceSecret`. An authenticated native
|
|
367
|
+
* holder's invalid-secret or `no_active_session` verdict suppresses key recovery
|
|
368
|
+
* while permitting a separately proved new shared holder. The latter verdict
|
|
369
|
+
* retains that holder and ends ACCOUNT recovery without a new key challenge.
|
|
370
|
+
* Classification still uses the existing 401 message markers, not a new parser.
|
|
371
|
+
* A transient error — or a durable-persist failure — leaves the store and
|
|
274
372
|
* returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
|
|
275
373
|
*
|
|
276
374
|
* Arm 2 (native Commons identity): when the secret is absent or was just
|
|
@@ -287,9 +385,10 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
287
385
|
*/
|
|
288
386
|
export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
|
|
289
387
|
const { oxy, store } = deps;
|
|
388
|
+
if (deps.isCurrent && !deps.isCurrent()) return null;
|
|
290
389
|
const identity = deps.identity ?? null;
|
|
291
390
|
// The shared identity is never an identity-bound client's recovery path.
|
|
292
|
-
const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? isNative());
|
|
391
|
+
const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? isNative()) && !(await store.isAutomaticIdentitySignInSuppressed?.());
|
|
293
392
|
const epoch = oxy.http.getSessionEpoch();
|
|
294
393
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
295
394
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
@@ -299,7 +398,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
299
398
|
return recoverIdentitySession(oxy, store, identity);
|
|
300
399
|
}
|
|
301
400
|
|
|
302
|
-
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin });
|
|
401
|
+
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin, isCurrent: deps.isCurrent });
|
|
303
402
|
switch (arm1.status) {
|
|
304
403
|
case 'ok':
|
|
305
404
|
return arm1.token;
|
|
@@ -324,6 +423,24 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
324
423
|
return null;
|
|
325
424
|
case 'invalid-secret':
|
|
326
425
|
case 'no-session': {
|
|
426
|
+
if (!identity && deps.sharedDeviceCredential) {
|
|
427
|
+
const joined = await refreshSharedDeviceArm({oxy, store, shared: deps.sharedDeviceCredential, rejectedLocalHolder: true, isCurrent: deps.isCurrent});
|
|
428
|
+
if (joined.status === 'ok') return joined.token;
|
|
429
|
+
if (joined.status === 'session-ended' || joined.status === 'persist-failed') return null;
|
|
430
|
+
}
|
|
431
|
+
if (!identity && arm1.status === 'invalid-secret' && await store.isAutomaticIdentitySignInSuppressed?.()) return null;
|
|
432
|
+
if (arm1.status === 'no-session' && !identity) {
|
|
433
|
+
if (store.setAutomaticIdentitySignInSuppressed) return null;
|
|
434
|
+
// The holder is still valid, but its account session ended. Retain only
|
|
435
|
+
// the holder for a later deliberate/shared sign-in; never use the key
|
|
436
|
+
// to turn this signed-out verdict into a new account session.
|
|
437
|
+
const persisted = await store.load();
|
|
438
|
+
if (persisted?.deviceId && persisted.deviceSecret) {
|
|
439
|
+
await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
// Identity mode retains its separate pinned-key recovery contract.
|
|
327
444
|
// 401: secret diverged or no live session. When a key-based arm 2 can still
|
|
328
445
|
// recover (the native Commons identity, or an identity-bound client's own
|
|
329
446
|
// primary key) drop ONLY the secret and keep the deviceId; otherwise (web) the
|
|
@@ -333,12 +450,6 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
333
450
|
if (persisted) {
|
|
334
451
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
335
452
|
}
|
|
336
|
-
} else if (arm1.status === 'no-session' && persisted?.deviceId && persisted.deviceSecret) {
|
|
337
|
-
// Web, a credential the server still recognises: this origin stays a
|
|
338
|
-
// holder of the browser's device (ADR 0029 D2) — the next sign-in in any
|
|
339
|
-
// app lands on it, and this app follows without opening the bridge
|
|
340
|
-
// again. Only the session fields go.
|
|
341
|
-
await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
|
|
342
453
|
} else {
|
|
343
454
|
await store.clear();
|
|
344
455
|
}
|
|
@@ -353,6 +464,11 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
353
464
|
);
|
|
354
465
|
break;
|
|
355
466
|
case 'no-secret':
|
|
467
|
+
if (!identity && deps.sharedDeviceCredential) {
|
|
468
|
+
const joined = await refreshSharedDeviceArm({oxy, store, shared: deps.sharedDeviceCredential, isCurrent: deps.isCurrent});
|
|
469
|
+
if (joined.status === 'ok') return joined.token;
|
|
470
|
+
if (joined.status === 'session-ended' || joined.status === 'persist-failed' || joined.status === 'transient') return null;
|
|
471
|
+
}
|
|
356
472
|
break;
|
|
357
473
|
}
|
|
358
474
|
|
|
@@ -362,14 +478,14 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
362
478
|
|
|
363
479
|
// Never after a sign-out: the shared identity is a KEY, not a session, and
|
|
364
480
|
// proving it here would sign the user straight back in.
|
|
365
|
-
if (allowCommonsIdentityFallback && !oxy.http.hasSessionEnded()) {
|
|
481
|
+
if (allowCommonsIdentityFallback && (deps.isCurrent?.() ?? true) && !oxy.http.hasSessionEnded() && !(await store.isAutomaticIdentitySignInSuppressed?.())) {
|
|
366
482
|
try {
|
|
367
483
|
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
368
484
|
// challenge round-trips must win, or the identity proof signs the user
|
|
369
485
|
// straight back in.
|
|
370
486
|
const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false });
|
|
371
487
|
if (session?.accessToken) {
|
|
372
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
488
|
+
if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
373
489
|
return null;
|
|
374
490
|
}
|
|
375
491
|
// Repopulate the fast device-secret lane from the identity re-mint.
|
|
@@ -383,7 +499,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
383
499
|
expiresAt: session.expiresAt,
|
|
384
500
|
});
|
|
385
501
|
}
|
|
386
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
502
|
+
if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
387
503
|
return null;
|
|
388
504
|
}
|
|
389
505
|
oxy.session.setAccessToken(session.accessToken);
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { logger } from '../logger';
|
|
47
|
-
import type { AuthStateStore, PersistedAuthState } from './authStateStore';
|
|
47
|
+
import type { AuthStateStore, AuthStateWriteGuard, PersistedAuthState } from './authStateStore';
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* The zero-cookie device credential, as shared between apps. Exactly the pair
|
|
@@ -294,7 +294,9 @@ export async function publishProvenDeviceCredential(deps: {
|
|
|
294
294
|
* seam, no lane left out, and no new call site to forget.
|
|
295
295
|
* - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
|
|
296
296
|
* a storage primitive, and would run on every `load()`. Adoption is an
|
|
297
|
-
* explicit
|
|
297
|
+
* explicit cold-boot/resume recovery instead; an existing local holder must
|
|
298
|
+
* first be rejected by the server, and a new shared holder must mint before
|
|
299
|
+
* conditional persistence.
|
|
298
300
|
*
|
|
299
301
|
* `clear()` deliberately does NOT clear the shared slot. This app signing out is
|
|
300
302
|
* not authority over the device-wide join point: other apps may still be signed
|
|
@@ -313,8 +315,18 @@ export function createSharedMirroringAuthStateStore(deps: {
|
|
|
313
315
|
let mirrored: SharedDeviceCredential | null = null;
|
|
314
316
|
|
|
315
317
|
return {
|
|
318
|
+
...local,
|
|
316
319
|
load: () => local.load(),
|
|
317
320
|
clear: () => local.clear(),
|
|
321
|
+
...(local.saveIfCurrent ? {saveIfCurrent: async (state: PersistedAuthState, guard: AuthStateWriteGuard) => {
|
|
322
|
+
const committed = await local.saveIfCurrent?.(state, guard);
|
|
323
|
+
if (!committed) return false;
|
|
324
|
+
const credential = readLocalDeviceCredential(state);
|
|
325
|
+
if (credential && guard.isCurrent()) {
|
|
326
|
+
try { await publishProvenDeviceCredential({shared, credential}); } catch { /* Local durability already verified. */ }
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}} : {}),
|
|
318
330
|
save: async (state) => {
|
|
319
331
|
// The durable local write is the contract this store owes its caller —
|
|
320
332
|
// 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.
|
|
@@ -24,19 +26,6 @@ const OFFICIAL_APEXES = new Set([
|
|
|
24
26
|
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
25
27
|
* or `[::1]` on any port, http or https).
|
|
26
28
|
*/
|
|
27
|
-
export function isLoopbackOrigin(origin: string): boolean {
|
|
28
|
-
try {
|
|
29
|
-
const parsed = new URL(origin);
|
|
30
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
const host = parsed.hostname.toLowerCase();
|
|
34
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
|
35
|
-
} catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
29
|
/** Whether an origin belongs to the official Oxy web ecosystem. */
|
|
41
30
|
export function isOfficialWebOrigin(origin: string): boolean {
|
|
42
31
|
if (isLoopbackOrigin(origin)) {
|