@oxyhq/core 8.0.0 → 9.0.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/boot/coldBootV2.js +66 -281
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +24 -92
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +14 -88
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -62
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +67 -279
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +25 -93
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +13 -87
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -62
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +22 -46
- package/dist/types/session/SessionClient.d.ts +4 -38
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +38 -43
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +2 -2
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +237 -236
- package/src/boot/coldBootV2.ts +92 -333
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +30 -115
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/session/SessionClient.ts +29 -120
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +47 -33
- package/src/session/__tests__/refresh.test.ts +72 -44
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +43 -111
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -83
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -152
- package/dist/esm/boot/deviceBootReturn.js +0 -146
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
- package/src/boot/deviceBootReturn.ts +0 -195
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import type { OxyServices } from '../../OxyServices';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
3
3
|
import type { SessionLoginResponse } from '../../models/session';
|
|
4
4
|
import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
|
|
5
5
|
import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
|
|
6
6
|
|
|
7
|
+
/** The persisted zero-cookie mint credential the refresh reads. */
|
|
7
8
|
const STORED: PersistedAuthState = {
|
|
8
9
|
sessionId: 'sess-old',
|
|
9
|
-
refreshToken: 'refresh-old-abcdefghij',
|
|
10
10
|
userId: 'user-1',
|
|
11
|
-
|
|
11
|
+
deviceId: 'dev-mint',
|
|
12
|
+
deviceSecret: 'ds-secret-orig',
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/** A successful `POST /session/device/token` mint (rotates the secret + active account). */
|
|
16
|
+
const MINT: DeviceTokenMintResponse = {
|
|
15
17
|
accessToken: 'access-new',
|
|
16
|
-
refreshToken: 'refresh-new-abcdefghij',
|
|
17
18
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
|
-
|
|
19
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
20
|
+
state: {
|
|
21
|
+
deviceId: 'dev-mint',
|
|
22
|
+
accounts: [{ accountId: 'user-1', sessionId: 'sess-new', authuser: 0 }],
|
|
23
|
+
activeAccountId: 'user-1',
|
|
24
|
+
revision: 4,
|
|
25
|
+
updatedAt: 1_700_000_000_000,
|
|
26
|
+
},
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
interface RefreshMockOverrides {
|
|
22
|
-
|
|
30
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
23
31
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -27,14 +35,14 @@ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setT
|
|
|
27
35
|
const setTokens = jest.fn();
|
|
28
36
|
const oxy = {
|
|
29
37
|
setTokens,
|
|
30
|
-
|
|
38
|
+
mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
31
39
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
32
40
|
} as unknown as OxyServices;
|
|
33
41
|
return { oxy, setTokens };
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
describe('refreshPersistedSession — arm 1 (
|
|
37
|
-
it('
|
|
44
|
+
describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
|
|
45
|
+
it('mints, plants, persists the rotated secret + active account, and returns the token', async () => {
|
|
38
46
|
const store = createMemoryAuthStateStore();
|
|
39
47
|
await store.save(STORED);
|
|
40
48
|
const { oxy, setTokens } = makeOxy();
|
|
@@ -45,53 +53,82 @@ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
|
|
|
45
53
|
expect(setTokens).toHaveBeenCalledWith('access-new');
|
|
46
54
|
expect(await store.load()).toEqual({
|
|
47
55
|
sessionId: 'sess-new',
|
|
48
|
-
refreshToken: 'refresh-new-abcdefghij',
|
|
49
56
|
userId: 'user-1',
|
|
50
|
-
|
|
57
|
+
deviceId: 'dev-mint',
|
|
58
|
+
deviceSecret: 'ds-next-secret',
|
|
51
59
|
accessToken: 'access-new',
|
|
52
60
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
53
61
|
});
|
|
54
62
|
});
|
|
55
63
|
|
|
56
|
-
it('
|
|
64
|
+
it('presents the persisted deviceId + deviceSecret to the mint', async () => {
|
|
57
65
|
const store = createMemoryAuthStateStore();
|
|
58
66
|
await store.save(STORED);
|
|
67
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT);
|
|
68
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
69
|
+
|
|
70
|
+
await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
71
|
+
|
|
72
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('drops only the secret (keeps deviceId) on a 401 and falls to shared-key when native', async () => {
|
|
76
|
+
const store = createMemoryAuthStateStore();
|
|
77
|
+
await store.save(STORED);
|
|
78
|
+
const SHARED: SessionLoginResponse = {
|
|
79
|
+
sessionId: 'sess-shared',
|
|
80
|
+
deviceId: 'dev-1',
|
|
81
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
82
|
+
user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
|
|
83
|
+
accessToken: 'access-shared',
|
|
84
|
+
};
|
|
85
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED);
|
|
59
86
|
const { oxy } = makeOxy({
|
|
60
|
-
|
|
61
|
-
throw Object.assign(new Error('
|
|
87
|
+
mintFromDeviceSecret: async () => {
|
|
88
|
+
throw Object.assign(new Error('invalid_device_secret'), { status: 401 });
|
|
62
89
|
},
|
|
90
|
+
signInWithSharedIdentity,
|
|
63
91
|
});
|
|
64
92
|
|
|
65
|
-
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback:
|
|
93
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
66
94
|
|
|
67
|
-
expect(token).
|
|
68
|
-
expect(
|
|
95
|
+
expect(token).toBe('access-shared');
|
|
96
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
97
|
+
const persisted = await store.load();
|
|
98
|
+
expect(persisted?.deviceSecret).toBeUndefined();
|
|
99
|
+
expect(persisted?.deviceId).toBe('dev-mint');
|
|
69
100
|
});
|
|
70
101
|
|
|
71
|
-
it('clears the store on
|
|
102
|
+
it('clears the store on a 401 when there is no shared-key fallback (web)', async () => {
|
|
72
103
|
const store = createMemoryAuthStateStore();
|
|
73
104
|
await store.save(STORED);
|
|
74
105
|
const { oxy } = makeOxy({
|
|
75
|
-
|
|
76
|
-
throw Object.assign(new Error('
|
|
106
|
+
mintFromDeviceSecret: async () => {
|
|
107
|
+
throw Object.assign(new Error('no_active_session'), { status: 401 });
|
|
77
108
|
},
|
|
78
109
|
});
|
|
79
110
|
|
|
80
|
-
|
|
111
|
+
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
112
|
+
|
|
113
|
+
expect(token).toBeNull();
|
|
81
114
|
expect(await store.load()).toBeNull();
|
|
82
115
|
});
|
|
83
116
|
|
|
84
|
-
it('KEEPS the store on a transient (500 / network) error', async () => {
|
|
117
|
+
it('KEEPS the store and returns null on a transient (500 / network) error', async () => {
|
|
85
118
|
const store = createMemoryAuthStateStore();
|
|
86
119
|
await store.save(STORED);
|
|
120
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
87
121
|
const { oxy } = makeOxy({
|
|
88
|
-
|
|
122
|
+
mintFromDeviceSecret: async () => {
|
|
89
123
|
throw Object.assign(new Error('server'), { status: 500 });
|
|
90
124
|
},
|
|
125
|
+
signInWithSharedIdentity,
|
|
91
126
|
});
|
|
92
127
|
|
|
93
|
-
|
|
128
|
+
// A transient failure must NOT drop the credential nor fall through to shared-key.
|
|
129
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
94
130
|
expect(await store.load()).toEqual(STORED);
|
|
131
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
95
132
|
});
|
|
96
133
|
});
|
|
97
134
|
|
|
@@ -104,8 +141,8 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
104
141
|
accessToken: 'access-shared',
|
|
105
142
|
};
|
|
106
143
|
|
|
107
|
-
it('re-mints via shared identity when there is no
|
|
108
|
-
const store = createMemoryAuthStateStore(); // no stored
|
|
144
|
+
it('re-mints via shared identity when there is no persisted secret', async () => {
|
|
145
|
+
const store = createMemoryAuthStateStore(); // no stored device secret
|
|
109
146
|
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
110
147
|
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
111
148
|
|
|
@@ -115,31 +152,22 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
|
|
|
115
152
|
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
116
153
|
});
|
|
117
154
|
|
|
118
|
-
it('
|
|
155
|
+
it('does NOT try shared-key when the fallback is disabled (web)', async () => {
|
|
119
156
|
const store = createMemoryAuthStateStore();
|
|
120
|
-
await store.save(STORED);
|
|
121
157
|
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
122
|
-
const { oxy } = makeOxy({
|
|
123
|
-
refreshWithToken: async () => {
|
|
124
|
-
throw Object.assign(new Error('revoked'), { status: 403 });
|
|
125
|
-
},
|
|
126
|
-
signInWithSharedIdentity,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
|
|
158
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
130
159
|
|
|
131
|
-
expect(
|
|
132
|
-
expect(
|
|
133
|
-
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
|
|
161
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
134
162
|
});
|
|
135
163
|
|
|
136
|
-
it('
|
|
164
|
+
it('returns null when the shared-key re-mint yields no session', async () => {
|
|
137
165
|
const store = createMemoryAuthStateStore();
|
|
138
|
-
const signInWithSharedIdentity = jest.fn(async () =>
|
|
166
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
139
167
|
const { oxy } = makeOxy({ signInWithSharedIdentity });
|
|
140
168
|
|
|
141
|
-
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback:
|
|
142
|
-
expect(signInWithSharedIdentity).
|
|
169
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
170
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
|
|
143
171
|
});
|
|
144
172
|
});
|
|
145
173
|
|
|
@@ -106,7 +106,7 @@ export interface AccountDialogControllerOptions {
|
|
|
106
106
|
* falls back to `SessionClient.registerAndActivate` (registration + activation
|
|
107
107
|
* only — no provider-side durable persist/hydration).
|
|
108
108
|
*/
|
|
109
|
-
commitSession?: (session: SessionLoginResponse
|
|
109
|
+
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
110
110
|
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
111
111
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
112
112
|
/** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
|
|
@@ -142,7 +142,7 @@ export class AccountDialogController {
|
|
|
142
142
|
private readonly sessionClient: SessionClient;
|
|
143
143
|
private readonly clientId: string | null;
|
|
144
144
|
private readonly locale?: string;
|
|
145
|
-
private readonly commitSession?: (session: SessionLoginResponse
|
|
145
|
+
private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
146
146
|
private readonly onSignedIn?: (user: MinimalUserData) => void;
|
|
147
147
|
private readonly idpApex: string;
|
|
148
148
|
private readonly pollIntervalMs: number;
|
|
@@ -454,7 +454,6 @@ export class AccountDialogController {
|
|
|
454
454
|
expiresAt: result.expiresAt,
|
|
455
455
|
user: result.user,
|
|
456
456
|
accessToken: result.accessToken,
|
|
457
|
-
...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
|
|
458
457
|
},
|
|
459
458
|
result.user,
|
|
460
459
|
);
|
|
@@ -633,7 +632,6 @@ export class AccountDialogController {
|
|
|
633
632
|
name: claimed.user.name,
|
|
634
633
|
avatar: claimed.user.avatar ?? undefined,
|
|
635
634
|
};
|
|
636
|
-
const refreshToken = readRefreshToken(claimed);
|
|
637
635
|
try {
|
|
638
636
|
await this.completeSignIn(
|
|
639
637
|
{
|
|
@@ -642,7 +640,6 @@ export class AccountDialogController {
|
|
|
642
640
|
expiresAt: claimed.expiresAt ?? '',
|
|
643
641
|
user: minimalUser,
|
|
644
642
|
accessToken: claimed.accessToken,
|
|
645
|
-
...(refreshToken ? { refreshToken } : {}),
|
|
646
643
|
},
|
|
647
644
|
minimalUser,
|
|
648
645
|
);
|
|
@@ -656,7 +653,7 @@ export class AccountDialogController {
|
|
|
656
653
|
* by the shared-key, QR, and mint-switch paths so they cannot drift.
|
|
657
654
|
*/
|
|
658
655
|
private async completeSignIn(
|
|
659
|
-
session: SessionLoginResponse
|
|
656
|
+
session: SessionLoginResponse,
|
|
660
657
|
user: MinimalUserData,
|
|
661
658
|
): Promise<void> {
|
|
662
659
|
await this.commitAuthorizedSession(session, user);
|
|
@@ -675,7 +672,7 @@ export class AccountDialogController {
|
|
|
675
672
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
676
673
|
*/
|
|
677
674
|
private async commitAuthorizedSession(
|
|
678
|
-
session: SessionLoginResponse
|
|
675
|
+
session: SessionLoginResponse,
|
|
679
676
|
user: MinimalUserData,
|
|
680
677
|
): Promise<void> {
|
|
681
678
|
if (this.commitSession) {
|
|
@@ -751,17 +748,6 @@ export function createAccountDialogController(
|
|
|
751
748
|
// Local helpers
|
|
752
749
|
// ---------------------------------------------------------------------------
|
|
753
750
|
|
|
754
|
-
/**
|
|
755
|
-
* The rotating refresh-token family head is threaded on the runtime object by
|
|
756
|
-
* the trusted device-flow / switch lanes even though it is NOT on the typed
|
|
757
|
-
* return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
|
|
758
|
-
* the commit funnel can persist a durable session.
|
|
759
|
-
*/
|
|
760
|
-
function readRefreshToken(value: unknown): string | undefined {
|
|
761
|
-
const token = (value as { refreshToken?: unknown }).refreshToken;
|
|
762
|
-
return typeof token === 'string' ? token : undefined;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
751
|
/** Current document URL on web; empty string where `location` is absent (native/SSR). */
|
|
766
752
|
function currentLocationHref(): string {
|
|
767
753
|
const location = (globalThis as { location?: { href?: string } }).location;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persisted auth state — ONE shape, web + native.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `load / save / clear` interface plus
|
|
8
|
-
*
|
|
9
|
-
* platform storage API directly.
|
|
4
|
+
* The zero-cookie device transport persists the device credential per ORIGIN so
|
|
5
|
+
* a reload restores the session locally without a redirect: `deviceId` +
|
|
6
|
+
* `deviceSecret` mint a fresh access token via `POST /session/device/token`.
|
|
7
|
+
* This module is the storage seam: a tiny `load / save / clear` interface plus
|
|
8
|
+
* platform factories, so the cold boot (`coldBootV2`) and the unified re-mint
|
|
9
|
+
* handler (`refresh.ts`) never touch a platform storage API directly.
|
|
10
10
|
*
|
|
11
11
|
* Platform-agnostic — the native factory takes an INJECTED key/value store
|
|
12
12
|
* (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
|
|
@@ -20,25 +20,38 @@
|
|
|
20
20
|
/**
|
|
21
21
|
* The persisted session credential set for a single origin.
|
|
22
22
|
*
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* `sessionId` + `userId` identify the owning device session and active account.
|
|
24
|
+
* `deviceId` + `deviceSecret` are the zero-cookie mint credential: the client
|
|
25
|
+
* presents BOTH at `POST /session/device/token` — the secret is the proof, the
|
|
26
|
+
* deviceId selects the device doc. The secret is rotated in-use: the mint returns
|
|
27
|
+
* `nextDeviceSecret`, which the cold boot / re-mint handler persist BEFORE
|
|
28
|
+
* planting the minted access token (multi-tab anti-loss).
|
|
27
29
|
*
|
|
28
|
-
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* refresh token is the dominant secret either way).
|
|
30
|
+
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them lets
|
|
31
|
+
* the cold boot plant a still-valid access token on the very first paint WITHOUT
|
|
32
|
+
* a blocking mint round-trip — the proactive scheduler then rotates it in the
|
|
33
|
+
* background. They are a strict optimization: the store is fully functional (via
|
|
34
|
+
* `deviceSecret`) when they are absent or stale, and the access token is
|
|
35
|
+
* short-lived, so persisting it adds no exposure the already-persisted
|
|
36
|
+
* `deviceSecret` does not.
|
|
36
37
|
*/
|
|
37
38
|
export interface PersistedAuthState {
|
|
38
39
|
sessionId: string;
|
|
39
|
-
refreshToken: string;
|
|
40
40
|
userId: string;
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The stable device identifier this session is bound to (zero-cookie
|
|
43
|
+
* transport). Persisted alongside {@link deviceSecret} because the
|
|
44
|
+
* `POST /session/device/token` mint presents BOTH. Sourced from every login
|
|
45
|
+
* lane (password / 2FA / QR claim / challenge verify) via the response's
|
|
46
|
+
* `deviceId`.
|
|
47
|
+
*/
|
|
48
|
+
deviceId?: string;
|
|
49
|
+
/**
|
|
50
|
+
* The rotating device secret (zero-cookie transport). Possession of it mints a
|
|
51
|
+
* short access token for the device's active account via
|
|
52
|
+
* `POST /session/device/token`. Rotated in-use.
|
|
53
|
+
*/
|
|
54
|
+
deviceSecret?: string;
|
|
42
55
|
/** Optional warm-boot access token (short-lived; see interface docs). */
|
|
43
56
|
accessToken?: string;
|
|
44
57
|
/** Optional warm-boot access-token expiry, ISO-8601. */
|
|
@@ -46,27 +59,15 @@ export interface PersistedAuthState {
|
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
|
-
* The storage seam consumed by the cold boot and the
|
|
62
|
+
* The storage seam consumed by the cold boot and the re-mint handler. Async
|
|
50
63
|
* throughout so one interface fits both synchronous web `localStorage` and
|
|
51
|
-
* asynchronous native SecureStore/AsyncStorage.
|
|
52
|
-
*
|
|
53
|
-
* Two lifetimes:
|
|
54
|
-
* - The SESSION credential blob (`load`/`save`/`clear`) is per-sign-in and is
|
|
55
|
-
* wiped on `clear()` (sign-out).
|
|
56
|
-
* - The DEVICE token (`loadDeviceToken`/`saveDeviceToken`/`clearDeviceToken`)
|
|
57
|
-
* is long-lived device attribution that SURVIVES `clear()`: a signed-out
|
|
58
|
-
* browser is still the same device, and a later in-app (cross-apex,
|
|
59
|
-
* cookie-less) login sends this token so the new session joins the SAME
|
|
60
|
-
* server-side DeviceSession. Only an explicit device signout-all
|
|
61
|
-
* (`clearDeviceToken`) removes it.
|
|
64
|
+
* asynchronous native SecureStore/AsyncStorage. `clear()` (sign-out) wipes the
|
|
65
|
+
* per-sign-in credential blob.
|
|
62
66
|
*/
|
|
63
67
|
export interface AuthStateStore {
|
|
64
68
|
load(): Promise<PersistedAuthState | null>;
|
|
65
69
|
save(state: PersistedAuthState): Promise<void>;
|
|
66
70
|
clear(): Promise<void>;
|
|
67
|
-
loadDeviceToken(): Promise<string | null>;
|
|
68
|
-
saveDeviceToken(token: string): Promise<void>;
|
|
69
|
-
clearDeviceToken(): Promise<void>;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/**
|
|
@@ -86,13 +87,6 @@ export interface NativeKeyValueStorage {
|
|
|
86
87
|
*/
|
|
87
88
|
export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
-
* Storage key for the long-lived device-attribution token. Separate from
|
|
91
|
-
* {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
|
|
92
|
-
* (sign-out) — the device is unchanged across sign-ins.
|
|
93
|
-
*/
|
|
94
|
-
export const DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
|
|
95
|
-
|
|
96
90
|
/**
|
|
97
91
|
* Parse + shape-validate a stored blob. Returns `null` for anything that is
|
|
98
92
|
* not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
|
|
@@ -114,21 +108,21 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
114
108
|
const candidate = parsed as Record<string, unknown>;
|
|
115
109
|
if (
|
|
116
110
|
typeof candidate.sessionId !== 'string' ||
|
|
117
|
-
typeof candidate.refreshToken !== 'string' ||
|
|
118
111
|
typeof candidate.userId !== 'string' ||
|
|
119
112
|
candidate.sessionId.length === 0 ||
|
|
120
|
-
candidate.refreshToken.length === 0 ||
|
|
121
113
|
candidate.userId.length === 0
|
|
122
114
|
) {
|
|
123
115
|
return null;
|
|
124
116
|
}
|
|
125
117
|
const state: PersistedAuthState = {
|
|
126
118
|
sessionId: candidate.sessionId,
|
|
127
|
-
refreshToken: candidate.refreshToken,
|
|
128
119
|
userId: candidate.userId,
|
|
129
120
|
};
|
|
130
|
-
if (typeof candidate.
|
|
131
|
-
state.
|
|
121
|
+
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
122
|
+
state.deviceId = candidate.deviceId;
|
|
123
|
+
}
|
|
124
|
+
if (typeof candidate.deviceSecret === 'string' && candidate.deviceSecret.length > 0) {
|
|
125
|
+
state.deviceSecret = candidate.deviceSecret;
|
|
132
126
|
}
|
|
133
127
|
if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
|
|
134
128
|
state.accessToken = candidate.accessToken;
|
|
@@ -147,7 +141,6 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
147
141
|
*/
|
|
148
142
|
export function createMemoryAuthStateStore(): AuthStateStore {
|
|
149
143
|
let current: PersistedAuthState | null = null;
|
|
150
|
-
let deviceToken: string | null = null;
|
|
151
144
|
return {
|
|
152
145
|
load: async () => current,
|
|
153
146
|
save: async (state) => {
|
|
@@ -156,13 +149,6 @@ export function createMemoryAuthStateStore(): AuthStateStore {
|
|
|
156
149
|
clear: async () => {
|
|
157
150
|
current = null;
|
|
158
151
|
},
|
|
159
|
-
loadDeviceToken: async () => deviceToken,
|
|
160
|
-
saveDeviceToken: async (token) => {
|
|
161
|
-
deviceToken = token;
|
|
162
|
-
},
|
|
163
|
-
clearDeviceToken: async () => {
|
|
164
|
-
deviceToken = null;
|
|
165
|
-
},
|
|
166
152
|
};
|
|
167
153
|
}
|
|
168
154
|
|
|
@@ -194,8 +180,8 @@ function safeGetLocalStorage(): Storage | null {
|
|
|
194
180
|
* for this page's lifetime — never throws on construction.
|
|
195
181
|
* - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
|
|
196
182
|
* throws yields `null`; a write that throws (quota, private mode) is
|
|
197
|
-
* swallowed. The
|
|
198
|
-
*
|
|
183
|
+
* swallowed. The re-mint lane treats a failed persist as "no durable state"
|
|
184
|
+
* rather than crashing.
|
|
199
185
|
*/
|
|
200
186
|
export function createWebAuthStateStore(): AuthStateStore {
|
|
201
187
|
const storage = safeGetLocalStorage();
|
|
@@ -207,7 +193,6 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
207
193
|
// means "never written this session" → fall back to storage; any set value
|
|
208
194
|
// (including `null` after clear) is authoritative and preferred over storage.
|
|
209
195
|
let sessionMirror: PersistedAuthState | null | undefined;
|
|
210
|
-
let deviceTokenMirror: string | null | undefined;
|
|
211
196
|
return {
|
|
212
197
|
load: async () => {
|
|
213
198
|
if (sessionMirror !== undefined) {
|
|
@@ -236,32 +221,6 @@ export function createWebAuthStateStore(): AuthStateStore {
|
|
|
236
221
|
// Non-fatal — see save().
|
|
237
222
|
}
|
|
238
223
|
},
|
|
239
|
-
loadDeviceToken: async () => {
|
|
240
|
-
if (deviceTokenMirror !== undefined) {
|
|
241
|
-
return deviceTokenMirror;
|
|
242
|
-
}
|
|
243
|
-
try {
|
|
244
|
-
return storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
245
|
-
} catch {
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
},
|
|
249
|
-
saveDeviceToken: async (token) => {
|
|
250
|
-
deviceTokenMirror = token;
|
|
251
|
-
try {
|
|
252
|
-
storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
|
|
253
|
-
} catch {
|
|
254
|
-
// Non-fatal — see save().
|
|
255
|
-
}
|
|
256
|
-
},
|
|
257
|
-
clearDeviceToken: async () => {
|
|
258
|
-
deviceTokenMirror = null;
|
|
259
|
-
try {
|
|
260
|
-
storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
261
|
-
} catch {
|
|
262
|
-
// Non-fatal — see save().
|
|
263
|
-
}
|
|
264
|
-
},
|
|
265
224
|
};
|
|
266
225
|
}
|
|
267
226
|
|
|
@@ -277,7 +236,6 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
277
236
|
// Same in-memory mirror as the web store — a locked/failed SecureStore write
|
|
278
237
|
// must not silently lose the session for the app's lifetime.
|
|
279
238
|
let sessionMirror: PersistedAuthState | null | undefined;
|
|
280
|
-
let deviceTokenMirror: string | null | undefined;
|
|
281
239
|
return {
|
|
282
240
|
load: async () => {
|
|
283
241
|
if (sessionMirror !== undefined) {
|
|
@@ -305,31 +263,5 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
|
|
|
305
263
|
// Non-fatal.
|
|
306
264
|
}
|
|
307
265
|
},
|
|
308
|
-
loadDeviceToken: async () => {
|
|
309
|
-
if (deviceTokenMirror !== undefined) {
|
|
310
|
-
return deviceTokenMirror;
|
|
311
|
-
}
|
|
312
|
-
try {
|
|
313
|
-
return await storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
314
|
-
} catch {
|
|
315
|
-
return null;
|
|
316
|
-
}
|
|
317
|
-
},
|
|
318
|
-
saveDeviceToken: async (token) => {
|
|
319
|
-
deviceTokenMirror = token;
|
|
320
|
-
try {
|
|
321
|
-
await storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
|
|
322
|
-
} catch {
|
|
323
|
-
// Non-fatal.
|
|
324
|
-
}
|
|
325
|
-
},
|
|
326
|
-
clearDeviceToken: async () => {
|
|
327
|
-
deviceTokenMirror = null;
|
|
328
|
-
try {
|
|
329
|
-
await storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
|
|
330
|
-
} catch {
|
|
331
|
-
// Non-fatal.
|
|
332
|
-
}
|
|
333
|
-
},
|
|
334
266
|
};
|
|
335
267
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
|
-
import { SessionClient, type
|
|
2
|
+
import { SessionClient, type TokenTransport } from './SessionClient';
|
|
3
3
|
import type { SocketIOFactory } from './socketLoader';
|
|
4
4
|
import { createSessionClientHost } from './sessionClientHost';
|
|
5
5
|
|
|
@@ -29,18 +29,11 @@ export function createSessionClient(
|
|
|
29
29
|
oxyServices: OxyServices,
|
|
30
30
|
transport: TokenTransport,
|
|
31
31
|
socketFactory?: SocketIOFactory,
|
|
32
|
-
/**
|
|
33
|
-
* Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
|
|
34
|
-
* while signed out so an idle tab receives its device pushes) and
|
|
35
|
-
* `onSessionAppeared` (self-acquire when a sibling signs in). See
|
|
36
|
-
* {@link SessionClientOptions}.
|
|
37
|
-
*/
|
|
38
|
-
extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>,
|
|
39
32
|
): {
|
|
40
33
|
client: SessionClient;
|
|
41
34
|
host: ReturnType<typeof createSessionClientHost>;
|
|
42
35
|
} {
|
|
43
36
|
const host = createSessionClientHost(oxyServices);
|
|
44
|
-
const client = new SessionClient(host, { transport, socketFactory
|
|
37
|
+
const client = new SessionClient(host, { transport, socketFactory });
|
|
45
38
|
return { client, host };
|
|
46
39
|
}
|