@oxy.so/core 1.9.1 → 1.11.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/crypto/deviceBackup.js +75 -0
- package/dist/cjs/crypto/keyManager.js +285 -12
- package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
- package/dist/cjs/mixins/OxyServices.user.js +7 -6
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/server/securityHeaders.js +3 -0
- 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/crypto/deviceBackup.js +70 -0
- package/dist/esm/crypto/keyManager.js +252 -12
- package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +7 -6
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/server/securityHeaders.js +3 -0
- 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/crypto/deviceBackup.d.ts +60 -0
- package/dist/types/crypto/keyManager.d.ts +79 -12
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
- package/dist/types/mixins/OxyServices.user.d.ts +8 -6
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +30 -0
- package/dist/types/server/securityHeaders.d.ts +5 -0
- package/dist/types/session/refresh.d.ts +7 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +2 -2
- 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/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
- package/src/crypto/deviceBackup.ts +108 -0
- package/src/crypto/keyManager.ts +278 -13
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
- package/src/mixins/OxyServices.user.ts +9 -7
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/mixins/index.ts +6 -0
- package/src/models/interfaces.ts +30 -1
- package/src/server/__tests__/securityHeaders.test.ts +7 -0
- package/src/server/cors.ts +0 -1
- package/src/server/securityHeaders.ts +8 -0
- 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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-accounts SDK surface: the paths, methods and bodies it sends, and the
|
|
3
|
+
* `{ data }` unwrapping. The flow itself is exercised in the API's route tests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { OxyServices } from '../../OxyServices';
|
|
7
|
+
|
|
8
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
9
|
+
const b64url = (obj: Record<string, unknown>): string => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
10
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
11
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
15
|
+
return new Response(status === 204 ? null : JSON.stringify({ data }), {
|
|
16
|
+
status,
|
|
17
|
+
headers: { 'content-type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LINK = {
|
|
22
|
+
id: 'link-1',
|
|
23
|
+
network: 'activitypub' as const,
|
|
24
|
+
accountKey: 'nate@mastodon.social',
|
|
25
|
+
actorUri: 'https://mastodon.social/users/nate',
|
|
26
|
+
handle: '@nate@mastodon.social',
|
|
27
|
+
host: 'mastodon.social',
|
|
28
|
+
proofMethod: 'oauth' as const,
|
|
29
|
+
verifiedAt: '2026-09-25T00:00:00.000Z',
|
|
30
|
+
createdAt: '2026-09-25T00:00:00.000Z',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('linked accounts', () => {
|
|
34
|
+
let originalFetch: typeof globalThis.fetch;
|
|
35
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
36
|
+
let oxy: OxyServices;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
originalFetch = globalThis.fetch;
|
|
40
|
+
fetchMock = jest.fn();
|
|
41
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
42
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
43
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
globalThis.fetch = originalFetch;
|
|
48
|
+
jest.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('starts a link with POST /linked-accounts/:network/start and returns the authorize URL', async () => {
|
|
52
|
+
fetchMock.mockResolvedValueOnce(
|
|
53
|
+
jsonResponse({ authorizeUrl: 'https://mastodon.social/oauth/authorize?x=1', expiresAt: '2026-09-25T00:10:00.000Z' }),
|
|
54
|
+
);
|
|
55
|
+
const started = await oxy.startLinkedAccount('activitypub', {
|
|
56
|
+
instance: 'mastodon.social',
|
|
57
|
+
clientId: 'oxy_dk_move',
|
|
58
|
+
returnTo: 'https://move.oxy.so/linked',
|
|
59
|
+
});
|
|
60
|
+
expect(started.authorizeUrl).toBe('https://mastodon.social/oauth/authorize?x=1');
|
|
61
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
62
|
+
expect(String(url)).toBe('http://test.invalid/linked-accounts/activitypub/start');
|
|
63
|
+
expect(init?.method).toBe('POST');
|
|
64
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
65
|
+
instance: 'mastodon.social',
|
|
66
|
+
clientId: 'oxy_dk_move',
|
|
67
|
+
returnTo: 'https://move.oxy.so/linked',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('completes a link with POST /linked-accounts/complete and returns the link', async () => {
|
|
72
|
+
fetchMock.mockImplementation(async (url) =>
|
|
73
|
+
String(url).endsWith('/linked-accounts/complete') ? jsonResponse({ linkedAccount: LINK }) : jsonResponse({ csrfToken: 't'.repeat(32) }),
|
|
74
|
+
);
|
|
75
|
+
expect(await oxy.completeLinkedAccount('one-time-code')).toEqual(LINK);
|
|
76
|
+
const completion = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/linked-accounts/complete'));
|
|
77
|
+
expect(completion?.[1]?.method).toBe('POST');
|
|
78
|
+
expect(JSON.parse(String(completion?.[1]?.body))).toEqual({ code: 'one-time-code' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('lists the live links, unwrapping the envelope, without caching', async () => {
|
|
82
|
+
fetchMock.mockImplementation(async () => jsonResponse({ linkedAccounts: [LINK] }));
|
|
83
|
+
expect(await oxy.listLinkedAccounts()).toEqual([LINK]);
|
|
84
|
+
await oxy.listLinkedAccounts();
|
|
85
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
86
|
+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://test.invalid/linked-accounts');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('revokes with DELETE /linked-accounts/:id', async () => {
|
|
90
|
+
// A state-changing request may first fetch a CSRF token; answer anything.
|
|
91
|
+
fetchMock.mockImplementation(async (_url, init) =>
|
|
92
|
+
init?.method === 'DELETE' ? new Response(null, { status: 204 }) : jsonResponse({ csrfToken: 't'.repeat(32) }),
|
|
93
|
+
);
|
|
94
|
+
await oxy.revokeLinkedAccount('link-1');
|
|
95
|
+
const deletion = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
|
|
96
|
+
expect(String(deletion?.[0])).toBe('http://test.invalid/linked-accounts/link-1');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reads a user\'s links on the service lane with a service token', async () => {
|
|
100
|
+
const service = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
101
|
+
jest.spyOn(service, 'getServiceToken').mockResolvedValue('service-token');
|
|
102
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ userId: 'u1', linkedAccounts: [{ ...LINK, federatedUserId: null }] }));
|
|
103
|
+
const result = await service.getLinkedAccountsForUser('u1');
|
|
104
|
+
expect(result.linkedAccounts[0].federatedUserId).toBeNull();
|
|
105
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
106
|
+
expect(String(url)).toBe('http://test.invalid/linked-accounts/by-user/u1');
|
|
107
|
+
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer service-token');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -24,8 +24,7 @@ function rawResponse(body: unknown): Response {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* A non-verified JWT whose payload decodes to the given claims. Puts the SDK in
|
|
27
|
-
* an authenticated state so the `POST /topics/resolve` carries a bearer header
|
|
28
|
-
* and skips the CSRF-token pre-fetch (which would otherwise consume a mock).
|
|
27
|
+
* an authenticated state so the `POST /topics/resolve` carries a bearer header.
|
|
29
28
|
*/
|
|
30
29
|
function makeJwt(payload: Record<string, unknown>): string {
|
|
31
30
|
const b64url = (obj: Record<string, unknown>): string =>
|
|
@@ -59,7 +58,7 @@ describe('topics mixin envelope unwrapping', () => {
|
|
|
59
58
|
fetchMock = jest.fn();
|
|
60
59
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
61
60
|
oxy = new OxyServices({ baseURL: 'http://test.invalid', enableRetry: false });
|
|
62
|
-
// Authenticate so the resolve POST
|
|
61
|
+
// Authenticate so the resolve POST carries a bearer. Harmless for GETs.
|
|
63
62
|
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
64
63
|
});
|
|
65
64
|
|
package/src/mixins/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
|
35
35
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
|
|
36
36
|
import { OxyServicesInferenceMixin } from './OxyServices.inference';
|
|
37
37
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
38
|
+
import { OxyServicesLinkedAccountsMixin } from './OxyServices.linkedAccounts';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -74,6 +75,7 @@ type AllMixinInstances =
|
|
|
74
75
|
& InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
|
|
75
76
|
& InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>>
|
|
76
77
|
& InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
|
|
78
|
+
& InstanceType<ReturnType<typeof OxyServicesLinkedAccountsMixin<typeof OxyServicesBase>>>
|
|
77
79
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
78
80
|
|
|
79
81
|
/**
|
|
@@ -165,6 +167,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
165
167
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
166
168
|
OxyServicesDeviceBootMixin,
|
|
167
169
|
|
|
170
|
+
// Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
|
|
171
|
+
// service read uses the auth mixin's `makeServiceRequest`.
|
|
172
|
+
OxyServicesLinkedAccountsMixin,
|
|
173
|
+
|
|
168
174
|
// Device-to-device identity transfer ("add a device"): E2E-encrypted key
|
|
169
175
|
// clone over a short-lived relay (b3 Feature 2).
|
|
170
176
|
|
package/src/models/interfaces.ts
CHANGED
|
@@ -115,6 +115,13 @@ export interface User {
|
|
|
115
115
|
* lists, profile search) — the two are separate fields on the User document.
|
|
116
116
|
*/
|
|
117
117
|
description?: string;
|
|
118
|
+
/**
|
|
119
|
+
* ActivityPub actor URIs this account is also known as — its live,
|
|
120
|
+
* OAuth-proven linked Mastodon-API accounts. Emitted by
|
|
121
|
+
* `GET /profiles/username/:username`; a relying app passes it to the shared
|
|
122
|
+
* actor builder (`@oxy.so/federation`) so its actor publishes it.
|
|
123
|
+
*/
|
|
124
|
+
alsoKnownAs?: string[];
|
|
118
125
|
phone?: string;
|
|
119
126
|
address?: string;
|
|
120
127
|
/** Legacy free-text birthday, kept for backward compatibility. Prefer `dateOfBirth`. */
|
|
@@ -263,7 +270,22 @@ export interface LoginResponse {
|
|
|
263
270
|
export interface Notification {
|
|
264
271
|
id: string;
|
|
265
272
|
message: string;
|
|
266
|
-
|
|
273
|
+
/** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
|
|
274
|
+
type?: string;
|
|
275
|
+
/**
|
|
276
|
+
* `system` notifications only (with `message`): the text an Oxy service sent
|
|
277
|
+
* about the recipient's own account. Render these two instead of composing a
|
|
278
|
+
* sentence from actor + entity.
|
|
279
|
+
*/
|
|
280
|
+
title?: string;
|
|
281
|
+
/** `system` notifications only: an optional deep link (https or the sending app's scheme). */
|
|
282
|
+
url?: string;
|
|
283
|
+
/**
|
|
284
|
+
* What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
|
|
285
|
+
* in the notifying application's namespace (`system` notifications only).
|
|
286
|
+
*/
|
|
287
|
+
entityType?: string;
|
|
288
|
+
entityId?: string;
|
|
267
289
|
}
|
|
268
290
|
|
|
269
291
|
export interface Wallet {
|
|
@@ -666,6 +688,13 @@ export interface ServiceAssetMetadata {
|
|
|
666
688
|
* play. Treat absence as "no adaptive stream, play the progressive original".
|
|
667
689
|
*/
|
|
668
690
|
hlsReadyAt?: string;
|
|
691
|
+
/**
|
|
692
|
+
* The Oxy user who owns the file (`null` for a system-owned file such as the
|
|
693
|
+
* federated media cache). PRESENT ONLY for Oxy's own applications (service
|
|
694
|
+
* tokens with `tier: 'internal'`), so a first-party service can check that an
|
|
695
|
+
* asset a user attached is really that user's. Absent for every other caller.
|
|
696
|
+
*/
|
|
697
|
+
ownerUserId?: string | null;
|
|
669
698
|
}
|
|
670
699
|
|
|
671
700
|
/**
|
|
@@ -211,6 +211,13 @@ describe('@oxy.so/core/server buildOxyPagesHeaders on a sensitive origin (ADR 00
|
|
|
211
211
|
expect(sensitive).toContain('https://api.oxy.so');
|
|
212
212
|
expect(sensitive).toContain("frame-ancestors 'none'");
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
it('sends no-transform so the edge never injects the beacon, and only there', () => {
|
|
216
|
+
expect(buildOxyPagesHeaders({ sensitive: true })).toContain(
|
|
217
|
+
'Cache-Control: public, max-age=0, must-revalidate, no-transform',
|
|
218
|
+
);
|
|
219
|
+
expect(buildOxyPagesHeaders()).not.toContain('Cache-Control');
|
|
220
|
+
});
|
|
214
221
|
});
|
|
215
222
|
|
|
216
223
|
describe('@oxy.so/core/server extractInlineScripts', () => {
|
package/src/server/cors.ts
CHANGED
|
@@ -366,6 +366,11 @@ export interface OxyPagesHeadersOptions {
|
|
|
366
366
|
* the app — is REMOVED from `script-src` and `connect-src`, so third-party
|
|
367
367
|
* measurement cannot run there even when the zone has Web Analytics enabled.
|
|
368
368
|
* A structural block in the policy the page is served with, not a build flag.
|
|
369
|
+
*
|
|
370
|
+
* It also sends `Cache-Control: no-transform`, which stops the edge from
|
|
371
|
+
* injecting the beacon at all — otherwise every page load logs a CSP
|
|
372
|
+
* violation for a script we never asked for. The rest of the value is Pages'
|
|
373
|
+
* own default, so caching is unchanged.
|
|
369
374
|
*/
|
|
370
375
|
sensitive?: boolean;
|
|
371
376
|
/**
|
|
@@ -429,6 +434,9 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
|
|
|
429
434
|
if (options.hsts !== false) {
|
|
430
435
|
lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
|
431
436
|
}
|
|
437
|
+
if (options.sensitive) {
|
|
438
|
+
lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
|
|
439
|
+
}
|
|
432
440
|
lines.push('');
|
|
433
441
|
return lines.join('\n');
|
|
434
442
|
}
|
|
@@ -82,7 +82,7 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
|
|
|
82
82
|
overrides.requestChallenge
|
|
83
83
|
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
84
84
|
verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
|
|
85
|
-
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
85
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight(), getSessionEpoch: () => 0 },
|
|
86
86
|
} as unknown as OxyServices;
|
|
87
87
|
return { oxy, setTokens };
|
|
88
88
|
}
|
|
@@ -59,8 +59,18 @@ interface RefreshMockOverrides {
|
|
|
59
59
|
|
|
60
60
|
function makeOxy(
|
|
61
61
|
overrides: RefreshMockOverrides = {},
|
|
62
|
-
): {
|
|
62
|
+
): {
|
|
63
|
+
oxy: OxyServices;
|
|
64
|
+
setTokens: jest.Mock;
|
|
65
|
+
noteRefreshRateLimited: jest.Mock;
|
|
66
|
+
/** `HttpService`'s session epoch; bump it to simulate a sign-out mid-refresh. */
|
|
67
|
+
epoch: { current: number };
|
|
68
|
+
/** `HttpService.hasSessionEnded()`: a sign-out with no token planted since. */
|
|
69
|
+
ended: { current: boolean };
|
|
70
|
+
} {
|
|
63
71
|
const setTokens = jest.fn();
|
|
72
|
+
const epoch = { current: 0 };
|
|
73
|
+
const ended = { current: false };
|
|
64
74
|
const noteRefreshRateLimited = jest.fn();
|
|
65
75
|
const oxy = {
|
|
66
76
|
setTokens,
|
|
@@ -71,9 +81,11 @@ function makeOxy(
|
|
|
71
81
|
httpService: {
|
|
72
82
|
runSingleFlightDeviceSecretMint: makeMintSingleFlight(),
|
|
73
83
|
noteRefreshRateLimited,
|
|
84
|
+
getSessionEpoch: () => epoch.current,
|
|
85
|
+
hasSessionEnded: () => ended.current,
|
|
74
86
|
},
|
|
75
87
|
} as unknown as OxyServices;
|
|
76
|
-
return { oxy, setTokens, noteRefreshRateLimited };
|
|
88
|
+
return { oxy, setTokens, noteRefreshRateLimited, epoch, ended };
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
|
|
@@ -297,6 +309,107 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
297
309
|
});
|
|
298
310
|
});
|
|
299
311
|
|
|
312
|
+
describe('refreshPersistedSession — a sign-out mid-refresh', () => {
|
|
313
|
+
it('arm 1: plants nothing and leaves a cleared store cleared', async () => {
|
|
314
|
+
const store = createMemoryAuthStateStore();
|
|
315
|
+
await store.save(STORED);
|
|
316
|
+
let respond!: () => void;
|
|
317
|
+
const { oxy, setTokens, epoch } = makeOxy({
|
|
318
|
+
mintFromDeviceSecret: () =>
|
|
319
|
+
new Promise((resolve) => {
|
|
320
|
+
respond = () => resolve(MINT);
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
326
|
+
// The sign-out: the credential is dropped and the session epoch moves.
|
|
327
|
+
await store.clear();
|
|
328
|
+
epoch.current += 1;
|
|
329
|
+
respond();
|
|
330
|
+
|
|
331
|
+
await expect(pending).resolves.toBeNull();
|
|
332
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
333
|
+
expect(await store.load()).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('arm 1: still persists the rotated secret when the store kept the credential', async () => {
|
|
337
|
+
const store = createMemoryAuthStateStore();
|
|
338
|
+
await store.save(STORED);
|
|
339
|
+
let respond!: () => void;
|
|
340
|
+
const { oxy, setTokens, epoch } = makeOxy({
|
|
341
|
+
mintFromDeviceSecret: () =>
|
|
342
|
+
new Promise((resolve) => {
|
|
343
|
+
respond = () => resolve(MINT);
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
349
|
+
// A local teardown that keeps the store (so a reload can restore): the
|
|
350
|
+
// server rotated the secret, and the store must not be left holding the old one.
|
|
351
|
+
epoch.current += 1;
|
|
352
|
+
respond();
|
|
353
|
+
|
|
354
|
+
await expect(pending).resolves.toBeNull();
|
|
355
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
356
|
+
expect((await store.load())?.deviceSecret).toBe('ds-next-secret');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('arm 2: the shared keychain does not sign the user back in', async () => {
|
|
360
|
+
const store = createMemoryAuthStateStore();
|
|
361
|
+
let respond!: () => void;
|
|
362
|
+
const signInWithSharedIdentity = jest.fn(
|
|
363
|
+
(_opts?: { plantTokens?: boolean }) =>
|
|
364
|
+
new Promise<SessionLoginResponse>((resolve) => {
|
|
365
|
+
respond = () =>
|
|
366
|
+
resolve({
|
|
367
|
+
sessionId: 'sess-shared',
|
|
368
|
+
deviceId: 'dev-1',
|
|
369
|
+
deviceSecret: 'ds-shared',
|
|
370
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
371
|
+
user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
|
|
372
|
+
accessToken: 'access-shared',
|
|
373
|
+
});
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
const { oxy, setTokens, epoch } = makeOxy({ signInWithSharedIdentity });
|
|
377
|
+
|
|
378
|
+
const pending = refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
379
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
380
|
+
epoch.current += 1;
|
|
381
|
+
respond();
|
|
382
|
+
|
|
383
|
+
await expect(pending).resolves.toBeNull();
|
|
384
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
|
|
385
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
386
|
+
expect(await store.load()).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('refreshPersistedSession — after a sign-out', () => {
|
|
391
|
+
it('does not re-sign-in with the shared keychain', async () => {
|
|
392
|
+
const store = createMemoryAuthStateStore();
|
|
393
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
394
|
+
const { oxy, setTokens, ended } = makeOxy({ signInWithSharedIdentity });
|
|
395
|
+
ended.current = true;
|
|
396
|
+
|
|
397
|
+
await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBeNull();
|
|
398
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
399
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('still mints from a device credential the store holds (a sign-in made in another tab)', async () => {
|
|
403
|
+
const store = createMemoryAuthStateStore();
|
|
404
|
+
await store.save(STORED);
|
|
405
|
+
const { oxy, setTokens, ended } = makeOxy();
|
|
406
|
+
ended.current = true;
|
|
407
|
+
|
|
408
|
+
await expect(refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).resolves.toBe('access-new');
|
|
409
|
+
expect(setTokens).toHaveBeenCalledWith('access-new');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
300
413
|
describe('refreshPersistedSession — single-flight (no double-rotation)', () => {
|
|
301
414
|
it('coalesces two concurrent mints into ONE server rotation; the store holds the final current secret', async () => {
|
|
302
415
|
// The server rotates the secret on every mint. Two concurrent lanes must
|
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`.
|