@oxy.so/core 1.9.1 → 1.10.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/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/mixins/OxyServices.user.js +2 -5
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/session/refresh.js +32 -2
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/mixins/OxyServices.user.js +2 -5
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/session/refresh.js +32 -2
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -4
- package/dist/types/session/refresh.d.ts +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +59 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/mixins/OxyServices.user.ts +2 -5
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/server/cors.ts +0 -1
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +38 -2
- package/src/utils/errorUtils.ts +1 -1
package/src/session/refresh.ts
CHANGED
|
@@ -114,6 +114,11 @@ export interface RefreshDeps {
|
|
|
114
114
|
* The device secret is FINE — it is the identity binding that went stale, so
|
|
115
115
|
* the caller must re-establish from the local key, never drop the credential.
|
|
116
116
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
117
|
+
* - `session-ended` — the mint succeeded, but the local session was ended
|
|
118
|
+
* (`HttpService.endSession`, i.e. a sign-out) while it was in flight. Nothing
|
|
119
|
+
* is planted. The rotated secret is persisted only if the store still holds
|
|
120
|
+
* the credential that was presented — a store the sign-out cleared stays
|
|
121
|
+
* clear.
|
|
117
122
|
* - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
|
|
118
123
|
* durably persisted. The token is deliberately NOT planted: advertising a
|
|
119
124
|
* healthy session on a secret that will not survive a reload is exactly the
|
|
@@ -126,6 +131,7 @@ export type DeviceSecretMintOutcome =
|
|
|
126
131
|
| { status: 'no-session' }
|
|
127
132
|
| { status: 'account-not-on-device' }
|
|
128
133
|
| { status: 'transient' }
|
|
134
|
+
| { status: 'session-ended' }
|
|
129
135
|
| { status: 'persist-failed' };
|
|
130
136
|
|
|
131
137
|
/**
|
|
@@ -159,6 +165,7 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
159
165
|
const { oxy, store } = deps;
|
|
160
166
|
const pin = deps.pin ?? null;
|
|
161
167
|
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
168
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
162
169
|
const persisted = await store.load();
|
|
163
170
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
164
171
|
return { status: 'no-secret' };
|
|
@@ -222,12 +229,26 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
222
229
|
expiresAt: mint.expiresAt,
|
|
223
230
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
224
231
|
};
|
|
232
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
233
|
+
// Signed out while minting. The server has rotated the secret, so a store
|
|
234
|
+
// that still holds the presented one must learn the rotation (the
|
|
235
|
+
// token-null lane keeps the store so a reload can restore); a store the
|
|
236
|
+
// sign-out cleared must NOT be refilled. Either way, plant nothing.
|
|
237
|
+
const current = await store.load();
|
|
238
|
+
if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
|
|
239
|
+
await store.save(next);
|
|
240
|
+
}
|
|
241
|
+
return { status: 'session-ended' };
|
|
242
|
+
}
|
|
225
243
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
226
244
|
// A failed durable persist must NOT plant.
|
|
227
245
|
const persistedOk = await store.save(next);
|
|
228
246
|
if (!persistedOk) {
|
|
229
247
|
return { status: 'persist-failed' };
|
|
230
248
|
}
|
|
249
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
250
|
+
return { status: 'session-ended' };
|
|
251
|
+
}
|
|
231
252
|
oxy.setTokens(mint.accessToken);
|
|
232
253
|
return {
|
|
233
254
|
status: 'ok',
|
|
@@ -266,6 +287,7 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
266
287
|
const identity = deps.identity ?? null;
|
|
267
288
|
// The shared keychain is never an identity-bound client's recovery path.
|
|
268
289
|
const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
|
|
290
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
269
291
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
270
292
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
271
293
|
// an unpinned mint would adopt whatever account the device switched to).
|
|
@@ -278,6 +300,8 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
278
300
|
switch (arm1.status) {
|
|
279
301
|
case 'ok':
|
|
280
302
|
return arm1.token;
|
|
303
|
+
case 'session-ended':
|
|
304
|
+
return null;
|
|
281
305
|
case 'transient':
|
|
282
306
|
logger.debug(
|
|
283
307
|
'Persisted deviceSecret mint failed (transient) — keeping store',
|
|
@@ -327,10 +351,18 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
327
351
|
return recoverIdentitySession(oxy, store, identity);
|
|
328
352
|
}
|
|
329
353
|
|
|
330
|
-
|
|
354
|
+
// Never after a sign-out: the shared keychain holds an identity KEY, not a
|
|
355
|
+
// session, and using it here would sign the user straight back in.
|
|
356
|
+
if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
|
|
331
357
|
try {
|
|
332
|
-
|
|
358
|
+
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
359
|
+
// challenge round-trips must win, or the shared keychain signs the user
|
|
360
|
+
// straight back in.
|
|
361
|
+
const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
|
|
333
362
|
if (session?.accessToken) {
|
|
363
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
334
366
|
// Repopulate the fast device-secret lane from the shared-key re-mint.
|
|
335
367
|
if (session.deviceId && session.deviceSecret) {
|
|
336
368
|
await store.save({
|
|
@@ -342,6 +374,10 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
342
374
|
expiresAt: session.expiresAt,
|
|
343
375
|
});
|
|
344
376
|
}
|
|
377
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
oxy.setTokens(session.accessToken);
|
|
345
381
|
return session.accessToken;
|
|
346
382
|
}
|
|
347
383
|
} catch (error) {
|
package/src/utils/errorUtils.ts
CHANGED
|
@@ -121,7 +121,7 @@ const nonEmptyString = (value: unknown): string | undefined =>
|
|
|
121
121
|
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
122
122
|
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
123
123
|
* and `error` is the machine code, so both survive.
|
|
124
|
-
* - `{ message, code }` — e.g. the API's
|
|
124
|
+
* - `{ message, code }` — e.g. the API's middleware rejections.
|
|
125
125
|
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
126
126
|
* `message`/`error_description` the string is the message, not a code: a bare
|
|
127
127
|
* `error` string is not machine-readable enough to promote to `code`.
|