@oxyhq/core 5.1.0 → 5.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/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +27 -5
- package/dist/cjs/OxyServices.base.js +71 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.accounts.js +10 -25
- package/dist/cjs/mixins/OxyServices.assets.js +76 -0
- package/dist/cjs/mixins/OxyServices.user.js +30 -11
- package/dist/cjs/utils/ssoBounce.js +59 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +27 -5
- package/dist/esm/OxyServices.base.js +71 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +10 -25
- package/dist/esm/mixins/OxyServices.assets.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +30 -11
- package/dist/esm/utils/ssoBounce.js +57 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.base.d.ts +38 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +36 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +1 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +1 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.links.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +34 -10
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +23 -0
- package/dist/types/utils/ssoBounce.d.ts +47 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +29 -5
- package/src/OxyServices.base.ts +79 -0
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +221 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +10 -33
- package/src/mixins/OxyServices.assets.ts +92 -1
- package/src/mixins/OxyServices.user.ts +43 -11
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/models/interfaces.ts +24 -0
- package/src/utils/__tests__/ssoBounce.test.ts +41 -0
- package/src/utils/ssoBounce.ts +61 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getServiceAssetMetadataBySha256` mixin tests (reverse content-address lookup).
|
|
3
|
+
*
|
|
4
|
+
* Stubs `makeServiceRequest` (the service-token transport used by the route's
|
|
5
|
+
* `serviceAuthMiddleware` + `files:read` scope) so the tests run with no network
|
|
6
|
+
* and no `getServiceToken()` round-trip, then asserts:
|
|
7
|
+
* - empty / whitespace / all-malformed input no-ops to `[]` with no network call;
|
|
8
|
+
* - hashes are lowercased, hex-validated (malformed dropped), and de-duplicated,
|
|
9
|
+
* and a single chunk is POSTed to `/assets/service/by-sha256` as `{ sha256s }`;
|
|
10
|
+
* - the `{ data }` envelope is unwrapped to a bare `ServiceAssetMetadataBySha[]`
|
|
11
|
+
* (mirroring how `makeServiceRequest<T[]>` returns the inner array), with the
|
|
12
|
+
* optional `url` preserved for public assets and absent for private ones;
|
|
13
|
+
* - inputs > 100 hashes are chunked at 100/request and merged;
|
|
14
|
+
* - a failed chunk is logged and skipped — successful chunks still return.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ServiceAssetMetadataBySha } from '../../models/interfaces';
|
|
18
|
+
import { OxyServices } from '../../OxyServices';
|
|
19
|
+
|
|
20
|
+
const publicEntry: ServiceAssetMetadataBySha = {
|
|
21
|
+
sha256: 'a'.repeat(64),
|
|
22
|
+
id: 'asset-1',
|
|
23
|
+
mime: 'image/jpeg',
|
|
24
|
+
size: 12345,
|
|
25
|
+
status: 'active',
|
|
26
|
+
url: 'https://cloud.oxy.so/content/2026/06/aa/abc.jpg',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const privateEntry: ServiceAssetMetadataBySha = {
|
|
30
|
+
sha256: 'b'.repeat(64),
|
|
31
|
+
id: 'asset-2',
|
|
32
|
+
mime: 'application/octet-stream',
|
|
33
|
+
size: 7,
|
|
34
|
+
status: 'active',
|
|
35
|
+
// no url — private/unlisted assets stream through the origin
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('OxyServices.assets — getServiceAssetMetadataBySha256', () => {
|
|
39
|
+
let oxy: OxyServices;
|
|
40
|
+
let makeServiceRequestSpy: jest.SpyInstance;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
44
|
+
makeServiceRequestSpy = jest.spyOn(oxy, 'makeServiceRequest');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
jest.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns [] and performs no network call for empty / malformed input', async () => {
|
|
52
|
+
await expect(oxy.getServiceAssetMetadataBySha256([])).resolves.toEqual([]);
|
|
53
|
+
// whitespace, wrong length, and non-hex are all dropped → no chunk to send
|
|
54
|
+
await expect(
|
|
55
|
+
oxy.getServiceAssetMetadataBySha256([' ', 'zzz', 'a'.repeat(63), 'a'.repeat(65)]),
|
|
56
|
+
).resolves.toEqual([]);
|
|
57
|
+
expect(makeServiceRequestSpy).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('lowercases, hex-validates, de-duplicates, and sends a single chunk', async () => {
|
|
61
|
+
// makeServiceRequest unwraps the API's `{ data }` envelope, so the resolved
|
|
62
|
+
// value is the bare array (NOT `{ data: [...] }`) — mirror that real shape.
|
|
63
|
+
makeServiceRequestSpy.mockResolvedValueOnce([publicEntry, privateEntry]);
|
|
64
|
+
|
|
65
|
+
const result = await oxy.getServiceAssetMetadataBySha256([
|
|
66
|
+
'A'.repeat(64), // uppercase → normalized to 'a'*64
|
|
67
|
+
'a'.repeat(64), // duplicate after lowercasing
|
|
68
|
+
'B'.repeat(64),
|
|
69
|
+
'not-hex', // dropped
|
|
70
|
+
' ', // dropped
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
expect(result).toEqual([publicEntry, privateEntry]);
|
|
74
|
+
// public asset carries url; private one does not
|
|
75
|
+
expect(result[0].url).toBe(publicEntry.url);
|
|
76
|
+
expect(result[1].url).toBeUndefined();
|
|
77
|
+
|
|
78
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledWith(
|
|
80
|
+
'POST',
|
|
81
|
+
'/assets/service/by-sha256',
|
|
82
|
+
{ sha256s: ['a'.repeat(64), 'b'.repeat(64)] },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('chunks at 100 hashes per request and merges each chunk', async () => {
|
|
87
|
+
// 250 distinct valid hex digests.
|
|
88
|
+
const shas = Array.from({ length: 250 }, (_, i) =>
|
|
89
|
+
i.toString(16).padStart(64, '0'),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
makeServiceRequestSpy.mockImplementation(
|
|
93
|
+
async (
|
|
94
|
+
_method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
95
|
+
_url: string,
|
|
96
|
+
data?: { sha256s: string[] },
|
|
97
|
+
): Promise<ServiceAssetMetadataBySha[]> =>
|
|
98
|
+
(data?.sha256s ?? []).map((sha) => ({
|
|
99
|
+
sha256: sha,
|
|
100
|
+
id: `asset-${sha}`,
|
|
101
|
+
mime: 'application/octet-stream',
|
|
102
|
+
size: 1,
|
|
103
|
+
status: 'active' as const,
|
|
104
|
+
})),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const result = await oxy.getServiceAssetMetadataBySha256(shas);
|
|
108
|
+
|
|
109
|
+
// 250 unique hashes => 100 + 100 + 50 across three POSTs.
|
|
110
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(3);
|
|
111
|
+
const chunkSizes = makeServiceRequestSpy.mock.calls.map(
|
|
112
|
+
(call) => (call[2] as { sha256s: string[] }).sha256s.length,
|
|
113
|
+
);
|
|
114
|
+
expect(chunkSizes).toEqual([100, 100, 50]);
|
|
115
|
+
|
|
116
|
+
expect(result).toHaveLength(250);
|
|
117
|
+
expect(result[0].sha256).toBe(shas[0]);
|
|
118
|
+
expect(result[249].sha256).toBe(shas[249]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('skips a failed chunk and returns the entries that resolved', async () => {
|
|
122
|
+
const shas = Array.from({ length: 150 }, (_, i) =>
|
|
123
|
+
i.toString(16).padStart(64, '0'),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
makeServiceRequestSpy
|
|
127
|
+
.mockResolvedValueOnce([publicEntry]) // first chunk (100 hashes) succeeds
|
|
128
|
+
.mockRejectedValueOnce(new Error('chunk failed')); // second chunk (50) fails
|
|
129
|
+
|
|
130
|
+
const result = await oxy.getServiceAssetMetadataBySha256(shas);
|
|
131
|
+
|
|
132
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
|
|
133
|
+
expect(result).toEqual([publicEntry]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getUsersByIds` dual-mode auth tests.
|
|
3
|
+
*
|
|
4
|
+
* `POST /users/by-ids` is `optionalUserOrServiceAuth` on oxy-api and returns
|
|
5
|
+
* the identical public `{ data: PublicUserProfile[] }` shape for both a service
|
|
6
|
+
* token and a user session. The SDK method must therefore:
|
|
7
|
+
* - use the bearer-SERVICE path (`makeServiceRequest`) when the instance was
|
|
8
|
+
* configured via `configureServiceAuth()` (the backend / hydration case), and
|
|
9
|
+
* - use the USER path (`makeRequest('POST', ..., { cache: false })`) otherwise
|
|
10
|
+
* (a browser / RN client with only a user session). Before the dual-mode fix
|
|
11
|
+
* the method always took the service path, so every client-side caller got
|
|
12
|
+
* `[]` because `getServiceToken()` had no credentials.
|
|
13
|
+
*
|
|
14
|
+
* Both paths must dedup + chunk, map every result through `normalizeUserIdentity`
|
|
15
|
+
* (so `name.displayName` is present), and tolerate a per-chunk failure without
|
|
16
|
+
* sinking the rest. `makeRequest` / `makeServiceRequest` are stubbed so the
|
|
17
|
+
* tests run with no network.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { OxyServices } from '../../OxyServices';
|
|
21
|
+
import type { User } from '../../models/interfaces';
|
|
22
|
+
|
|
23
|
+
const makeRawUser = (id: string): User =>
|
|
24
|
+
// The wire payload is a PublicUserProfile with server-owned name.displayName.
|
|
25
|
+
// Cast through the User shape the API returns post-unwrap.
|
|
26
|
+
({
|
|
27
|
+
id,
|
|
28
|
+
username: `user_${id}`,
|
|
29
|
+
name: { displayName: `Display ${id}` },
|
|
30
|
+
_count: { followers: 1, following: 2 },
|
|
31
|
+
}) as unknown as User;
|
|
32
|
+
|
|
33
|
+
describe('OxyServices.getUsersByIds — dual-mode auth', () => {
|
|
34
|
+
let oxy: OxyServices;
|
|
35
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
36
|
+
let makeServiceRequestSpy: jest.SpyInstance;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
40
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
41
|
+
makeServiceRequestSpy = jest.spyOn(
|
|
42
|
+
oxy as unknown as { makeServiceRequest: jest.Mock },
|
|
43
|
+
'makeServiceRequest',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
jest.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns [] and performs no network call for empty / whitespace input', async () => {
|
|
52
|
+
await expect(oxy.getUsersByIds([])).resolves.toEqual([]);
|
|
53
|
+
await expect(oxy.getUsersByIds(['', ' '])).resolves.toEqual([]);
|
|
54
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
55
|
+
expect(makeServiceRequestSpy).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('without service credentials (client / user-session path)', () => {
|
|
59
|
+
it('uses makeRequest with the user bearer and cache:false', async () => {
|
|
60
|
+
makeRequestSpy.mockResolvedValueOnce([makeRawUser('a'), makeRawUser('b')]);
|
|
61
|
+
|
|
62
|
+
const result = await oxy.getUsersByIds(['a', 'b']);
|
|
63
|
+
|
|
64
|
+
expect(makeServiceRequestSpy).not.toHaveBeenCalled();
|
|
65
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
67
|
+
'POST',
|
|
68
|
+
'/users/by-ids',
|
|
69
|
+
{ ids: ['a', 'b'] },
|
|
70
|
+
{ cache: false },
|
|
71
|
+
);
|
|
72
|
+
// normalizeUserIdentity ran on every entry (display name preserved).
|
|
73
|
+
expect(result).toHaveLength(2);
|
|
74
|
+
expect(result[0].name.displayName).toBe('Display a');
|
|
75
|
+
expect(result[1].id).toBe('b');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('de-duplicates ids and drops blanks before the request', async () => {
|
|
79
|
+
makeRequestSpy.mockResolvedValueOnce([makeRawUser('a')]);
|
|
80
|
+
|
|
81
|
+
await oxy.getUsersByIds(['a', 'a', ' ', 'a']);
|
|
82
|
+
|
|
83
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
85
|
+
'POST',
|
|
86
|
+
'/users/by-ids',
|
|
87
|
+
{ ids: ['a'] },
|
|
88
|
+
{ cache: false },
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('with service credentials (backend / hydration path)', () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
oxy.configureServiceAuth('oxy_dk_key', 'secret');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('uses makeServiceRequest and never the user path — unchanged behavior', async () => {
|
|
99
|
+
makeServiceRequestSpy.mockResolvedValueOnce([makeRawUser('a'), makeRawUser('b')]);
|
|
100
|
+
|
|
101
|
+
const result = await oxy.getUsersByIds(['a', 'b']);
|
|
102
|
+
|
|
103
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
104
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(makeServiceRequestSpy).toHaveBeenCalledWith('POST', '/users/by-ids', {
|
|
106
|
+
ids: ['a', 'b'],
|
|
107
|
+
});
|
|
108
|
+
expect(result).toHaveLength(2);
|
|
109
|
+
expect(result[0].name.displayName).toBe('Display a');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('chunking + resilience (applies to both modes)', () => {
|
|
114
|
+
it('chunks at 100 ids per request on the user path and flattens results', async () => {
|
|
115
|
+
const ids = Array.from({ length: 250 }, (_, i) => `id-${i}`);
|
|
116
|
+
makeRequestSpy.mockImplementation(
|
|
117
|
+
async (
|
|
118
|
+
_method: string,
|
|
119
|
+
_url: string,
|
|
120
|
+
data?: { ids: string[] },
|
|
121
|
+
): Promise<User[]> => (data?.ids ?? []).map((id) => makeRawUser(id)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = await oxy.getUsersByIds(ids);
|
|
125
|
+
|
|
126
|
+
// 250 unique ids => 100 + 100 + 50 across three POSTs.
|
|
127
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
|
|
128
|
+
const chunkSizes = makeRequestSpy.mock.calls.map(
|
|
129
|
+
(call) => (call[2] as { ids: string[] }).ids.length,
|
|
130
|
+
);
|
|
131
|
+
expect(chunkSizes).toEqual([100, 100, 50]);
|
|
132
|
+
expect(result).toHaveLength(250);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('skips a failed chunk and returns the users that resolved (user path)', async () => {
|
|
136
|
+
const ids = Array.from({ length: 150 }, (_, i) => `id-${i}`);
|
|
137
|
+
makeRequestSpy
|
|
138
|
+
.mockResolvedValueOnce([makeRawUser('id-0')])
|
|
139
|
+
.mockRejectedValueOnce(new Error('chunk boom'));
|
|
140
|
+
|
|
141
|
+
const result = await oxy.getUsersByIds(ids);
|
|
142
|
+
|
|
143
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
|
|
144
|
+
// The failed second chunk contributes nothing; the first survives.
|
|
145
|
+
expect(result).toHaveLength(1);
|
|
146
|
+
expect(result[0].id).toBe('id-0');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/models/interfaces.ts
CHANGED
|
@@ -533,6 +533,30 @@ export interface ServiceAssetMetadata {
|
|
|
533
533
|
status: 'active' | 'trash';
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Reverse-lookup asset metadata returned by `POST /assets/service/by-sha256`.
|
|
538
|
+
*
|
|
539
|
+
* Resolves a content-addressed `sha256` digest back to the live Oxy asset that
|
|
540
|
+
* holds those bytes: its file `id`, MIME type, byte `size`, storage `status`,
|
|
541
|
+
* and — for active, public, CDN-reachable assets only — a public `url`
|
|
542
|
+
* (`cloud.oxy.so`). This is the inverse of {@link ServiceAssetMetadata}: it lets
|
|
543
|
+
* a `files:read`-scoped service-to-server caller (e.g. Mention's MTN materializer
|
|
544
|
+
* / node-blob sync) turn a record's `blob.sha256` into a servable asset.
|
|
545
|
+
*
|
|
546
|
+
* `url` is omitted for private/unlisted assets (and for public assets whose
|
|
547
|
+
* bytes are not yet CDN-reachable) — those must be streamed through the origin.
|
|
548
|
+
* Unknown or deleted hashes are omitted from the response (never error the whole
|
|
549
|
+
* batch), so the result may be shorter than the requested hash list.
|
|
550
|
+
*/
|
|
551
|
+
export interface ServiceAssetMetadataBySha {
|
|
552
|
+
sha256: string;
|
|
553
|
+
id: string;
|
|
554
|
+
mime: string;
|
|
555
|
+
size: number;
|
|
556
|
+
status: 'active' | 'trash';
|
|
557
|
+
url?: string;
|
|
558
|
+
}
|
|
559
|
+
|
|
536
560
|
/**
|
|
537
561
|
* Account storage usage (server-side usage, not local AsyncStorage)
|
|
538
562
|
*/
|
|
@@ -16,9 +16,11 @@ import {
|
|
|
16
16
|
ssoNoSessionKey,
|
|
17
17
|
ssoAttemptedKey,
|
|
18
18
|
ssoPriorSessionKey,
|
|
19
|
+
ssoSignedOutKey,
|
|
19
20
|
buildSsoBounceUrl,
|
|
20
21
|
isCentralIdPOrigin,
|
|
21
22
|
guardActive,
|
|
23
|
+
silentRestoreSuppressed,
|
|
22
24
|
allowSsoBounce,
|
|
23
25
|
} from '../ssoBounce';
|
|
24
26
|
import { CENTRAL_AUTH_URL } from '../authWebUrl';
|
|
@@ -40,11 +42,50 @@ describe('per-origin key builders', () => {
|
|
|
40
42
|
expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
|
|
41
43
|
expect(ssoAttemptedKey(origin)).toBe('oxy_sso_attempted:https://mention.earth');
|
|
42
44
|
expect(ssoPriorSessionKey(origin)).toBe('oxy_sso_prior_session:https://mention.earth');
|
|
45
|
+
expect(ssoSignedOutKey(origin)).toBe('oxy_signed_out:https://mention.earth');
|
|
43
46
|
});
|
|
44
47
|
|
|
45
48
|
it('namespaces keys per origin so two RPs never collide', () => {
|
|
46
49
|
expect(ssoStateKey('https://a.test')).not.toBe(ssoStateKey('https://b.test'));
|
|
47
50
|
expect(ssoPriorSessionKey('https://a.test')).not.toBe(ssoPriorSessionKey('https://b.test'));
|
|
51
|
+
expect(ssoSignedOutKey('https://a.test')).not.toBe(ssoSignedOutKey('https://b.test'));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('silentRestoreSuppressed (deliberately-signed-out gate)', () => {
|
|
56
|
+
const origin = 'https://accounts.oxy.so';
|
|
57
|
+
|
|
58
|
+
function storageWith(value: string | null): Pick<Storage, 'getItem'> {
|
|
59
|
+
return { getItem: (key: string) => (key === ssoSignedOutKey(origin) ? value : null) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('is SUPPRESSED when the signed-out flag is set to "1" for this origin', () => {
|
|
63
|
+
expect(silentRestoreSuppressed(storageWith('1'), origin)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is NOT suppressed when the flag is absent (normal restore)', () => {
|
|
67
|
+
expect(silentRestoreSuppressed(storageWith(null), origin)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is NOT suppressed for any non-"1" value (only the exact set value gates)', () => {
|
|
71
|
+
expect(silentRestoreSuppressed(storageWith('0'), origin)).toBe(false);
|
|
72
|
+
expect(silentRestoreSuppressed(storageWith(''), origin)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is per-origin: another origin\'s flag does not suppress this one', () => {
|
|
76
|
+
const otherOriginFlag: Pick<Storage, 'getItem'> = {
|
|
77
|
+
getItem: (key: string) => (key === ssoSignedOutKey('https://other.test') ? '1' : null),
|
|
78
|
+
};
|
|
79
|
+
expect(silentRestoreSuppressed(otherOriginFlag, origin)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('fails safe (NOT suppressed, never throws) when getItem throws', () => {
|
|
83
|
+
const throwing: Pick<Storage, 'getItem'> = {
|
|
84
|
+
getItem: () => {
|
|
85
|
+
throw new Error('storage locked');
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
expect(silentRestoreSuppressed(throwing, origin)).toBe(false);
|
|
48
89
|
});
|
|
49
90
|
});
|
|
50
91
|
|
package/src/utils/ssoBounce.ts
CHANGED
|
@@ -69,6 +69,7 @@ const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
|
|
|
69
69
|
const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
|
|
70
70
|
const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
|
|
71
71
|
const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
|
|
72
|
+
const SIGNED_OUT_KEY_PREFIX = 'oxy_signed_out:';
|
|
72
73
|
|
|
73
74
|
/** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
|
|
74
75
|
export function ssoStateKey(origin: string): string {
|
|
@@ -125,6 +126,37 @@ export function ssoPriorSessionKey(origin: string): string {
|
|
|
125
126
|
return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Per-origin DURABLE "the user DELIBERATELY signed out on this device/origin"
|
|
131
|
+
* flag.
|
|
132
|
+
*
|
|
133
|
+
* Like {@link ssoPriorSessionKey} this lives in DURABLE storage (web
|
|
134
|
+
* `localStorage`; services uses its own `storageKeyPrefix`-scoped key), NOT the
|
|
135
|
+
* per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
|
|
136
|
+
*
|
|
137
|
+
* It exists purely to suppress AUTOMATIC silent restore after a deliberate
|
|
138
|
+
* sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
|
|
139
|
+
* credential association) would otherwise let `fedcm-silent` / the per-apex
|
|
140
|
+
* `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
|
|
141
|
+
* who pressed "Sign out" gets silently signed back in on reload. With this flag
|
|
142
|
+
* set, those silent cold-boot steps are skipped while the Gmail-style
|
|
143
|
+
* returning-account fast-path is otherwise preserved.
|
|
144
|
+
*
|
|
145
|
+
* Lifecycle (mirrors the existing gate machinery — set on a definitive event,
|
|
146
|
+
* cleared on its inverse):
|
|
147
|
+
* - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
|
|
148
|
+
* and the SSO bounce state).
|
|
149
|
+
* - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
|
|
150
|
+
* claim) so a real sign-in fully re-enables silent restore — there is no
|
|
151
|
+
* "stuck signed out" state.
|
|
152
|
+
*
|
|
153
|
+
* NOTE: this gates only AUTOMATIC/silent restore. An INTERACTIVE sign-in always
|
|
154
|
+
* clears it first, so the user can always sign back in.
|
|
155
|
+
*/
|
|
156
|
+
export function ssoSignedOutKey(origin: string): string {
|
|
157
|
+
return `${SIGNED_OUT_KEY_PREFIX}${origin}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
128
160
|
/**
|
|
129
161
|
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
130
162
|
*
|
|
@@ -267,6 +299,35 @@ export function guardActive(
|
|
|
267
299
|
return now - ts < SSO_GUARD_TTL_MS;
|
|
268
300
|
}
|
|
269
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
|
|
304
|
+
* user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
|
|
305
|
+
*
|
|
306
|
+
* When `true`, the silent cold-boot steps that can re-mint a session from a
|
|
307
|
+
* still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
|
|
308
|
+
* `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
|
|
309
|
+
* not silently signed back in on the next reload. Interactive sign-in clears the
|
|
310
|
+
* flag, so this never blocks a deliberate re-sign-in.
|
|
311
|
+
*
|
|
312
|
+
* Defensive: a `getItem` that throws (locked/disabled storage) is treated as NOT
|
|
313
|
+
* suppressed, so the gate fails toward the normal restore behaviour rather than
|
|
314
|
+
* wedging the user out.
|
|
315
|
+
*
|
|
316
|
+
* @param storage - The DURABLE storage to read (web `localStorage`; injected for
|
|
317
|
+
* testability).
|
|
318
|
+
* @param origin - The page origin whose signed-out flag to evaluate.
|
|
319
|
+
*/
|
|
320
|
+
export function silentRestoreSuppressed(
|
|
321
|
+
storage: Pick<Storage, 'getItem'>,
|
|
322
|
+
origin: string,
|
|
323
|
+
): boolean {
|
|
324
|
+
try {
|
|
325
|
+
return storage.getItem(ssoSignedOutKey(origin)) === '1';
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
270
331
|
/**
|
|
271
332
|
* Inputs to the smart {@link allowSsoBounce} gate.
|
|
272
333
|
*/
|