@oxyhq/core 9.1.0 → 9.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/README.md +1 -1
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/{coldBootV2.js → sessionColdBoot.js} +2 -2
- package/dist/cjs/index.js +23 -4
- package/dist/cjs/mixins/OxyServices.auth.js +61 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +29 -1
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/session/SessionClient.js +57 -25
- package/dist/cjs/session/accountDialogController.js +20 -9
- package/dist/cjs/session/authStateStore.js +13 -7
- package/dist/cjs/session/hubSync.js +55 -0
- package/dist/cjs/session/sessionClientHost.js +5 -0
- package/dist/cjs/utils/coldBoot.js +1 -1
- package/dist/cjs/utils/oauthPkce.js +65 -1
- package/dist/cjs/utils/officialOrigins.js +128 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/{coldBootV2.js → sessionColdBoot.js} +2 -2
- package/dist/esm/index.js +4 -2
- package/dist/esm/mixins/OxyServices.auth.js +61 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +30 -2
- package/dist/esm/server/index.js +1 -0
- package/dist/esm/session/SessionClient.js +57 -25
- package/dist/esm/session/accountDialogController.js +20 -9
- package/dist/esm/session/authStateStore.js +13 -7
- package/dist/esm/session/hubSync.js +51 -0
- package/dist/esm/session/sessionClientHost.js +5 -0
- package/dist/esm/utils/coldBoot.js +1 -1
- package/dist/esm/utils/oauthPkce.js +60 -0
- package/dist/esm/utils/officialOrigins.js +119 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/{coldBootV2.d.ts → sessionColdBoot.d.ts} +1 -1
- package/dist/types/index.d.ts +7 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +14 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +6 -2
- package/dist/types/server/index.d.ts +1 -0
- package/dist/types/session/SessionClient.d.ts +6 -0
- package/dist/types/session/accountDialogController.d.ts +9 -1
- package/dist/types/session/authStateStore.d.ts +1 -1
- package/dist/types/session/hubSync.d.ts +20 -0
- package/dist/types/session/sessionClientHost.d.ts +2 -1
- package/dist/types/utils/coldBoot.d.ts +2 -2
- package/dist/types/utils/oauthPkce.d.ts +27 -0
- package/dist/types/utils/officialOrigins.d.ts +17 -0
- package/package.json +2 -2
- package/src/boot/__tests__/{coldBootV2.test.ts → sessionColdBoot.test.ts} +1 -1
- package/src/boot/{coldBootV2.ts → sessionColdBoot.ts} +2 -2
- package/src/index.ts +27 -3
- package/src/mixins/OxyServices.auth.ts +70 -1
- package/src/mixins/OxyServices.deviceBoot.ts +46 -1
- package/src/server/index.ts +8 -0
- package/src/session/SessionClient.ts +68 -26
- package/src/session/__tests__/SessionClient.additive.test.ts +1 -0
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +1 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +1 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +1 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +1 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +1 -0
- package/src/session/__tests__/SessionClient.state.test.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +38 -6
- package/src/session/__tests__/sessionIntegration.test.ts +7 -0
- package/src/session/accountDialogController.ts +36 -10
- package/src/session/authStateStore.ts +18 -9
- package/src/session/hubSync.ts +79 -0
- package/src/session/sessionClientHost.ts +10 -2
- package/src/utils/__tests__/officialOrigins.test.ts +74 -0
- package/src/utils/coldBoot.ts +2 -2
- package/src/utils/oauthPkce.ts +71 -0
- package/src/utils/officialOrigins.ts +124 -0
|
@@ -13,10 +13,17 @@ export interface TokenTransport {
|
|
|
13
13
|
ensureActiveToken(state: DeviceSessionState): Promise<void>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface DeviceCredential {
|
|
17
|
+
deviceId: string;
|
|
18
|
+
deviceSecret: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export interface SessionClientHost {
|
|
17
22
|
makeRequest<T>(method: 'GET' | 'POST', url: string, data?: unknown, options?: { cache?: boolean }): Promise<T>;
|
|
18
23
|
getBaseURL(): string;
|
|
19
24
|
getAccessToken(): string | null;
|
|
25
|
+
/** Zero-cookie device credential for socket handshake when no bearer is planted yet. */
|
|
26
|
+
getDeviceCredential(): DeviceCredential | null;
|
|
20
27
|
onTokensChanged(listener: (token: string | null) => void): () => void;
|
|
21
28
|
setTokens(accessToken: string): void;
|
|
22
29
|
getCurrentAccountId(): string | null;
|
|
@@ -123,20 +130,33 @@ export class SessionClient {
|
|
|
123
130
|
return false;
|
|
124
131
|
}
|
|
125
132
|
this.state = next;
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
const transport = this.options.transport;
|
|
134
|
+
const needsMintBeforeNotify =
|
|
135
|
+
transport != null && next.accounts.length > 0 && !this.host.getAccessToken();
|
|
136
|
+
|
|
137
|
+
const finishApply = (): void => {
|
|
138
|
+
this.notify();
|
|
139
|
+
if (next.accounts.length === 0 && this.options.onUnauthenticated) {
|
|
140
|
+
try {
|
|
141
|
+
this.options.onUnauthenticated();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (needsMintBeforeNotify) {
|
|
149
|
+
void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
|
|
129
150
|
logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
151
|
+
finishApply();
|
|
130
152
|
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this.options.onUnauthenticated();
|
|
137
|
-
} catch (error) {
|
|
138
|
-
logger.error('[SessionClient] onUnauthenticated threw', error);
|
|
153
|
+
} else {
|
|
154
|
+
if (transport) {
|
|
155
|
+
void transport.ensureActiveToken(next).catch((error) => {
|
|
156
|
+
logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
|
|
157
|
+
});
|
|
139
158
|
}
|
|
159
|
+
finishApply();
|
|
140
160
|
}
|
|
141
161
|
return true;
|
|
142
162
|
}
|
|
@@ -222,18 +242,23 @@ export class SessionClient {
|
|
|
222
242
|
this.started = true;
|
|
223
243
|
this.tokenUnsub = this.host.onTokensChanged((token) => {
|
|
224
244
|
// A rotated/fresh bearer landed — reconnect a dropped socket so its
|
|
225
|
-
// handshake re-runs with the current token. Sign-out (null token)
|
|
226
|
-
//
|
|
227
|
-
if (!
|
|
228
|
-
if (
|
|
245
|
+
// handshake re-runs with the current token. Sign-out (null token) keeps
|
|
246
|
+
// the device-scoped socket when a device credential is available.
|
|
247
|
+
if (!this.socket) return;
|
|
248
|
+
if (token) {
|
|
249
|
+
if (!this.socket.connected) {
|
|
250
|
+
this.socket.connect();
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const cred = this.host.getDeviceCredential();
|
|
255
|
+
if (cred && !this.socket.connected) {
|
|
229
256
|
this.socket.connect();
|
|
230
257
|
}
|
|
231
258
|
});
|
|
232
259
|
this.openBroadcastChannel();
|
|
233
|
-
//
|
|
234
|
-
// signed-out
|
|
235
|
-
// failure is non-fatal — the socket still connects so realtime sync
|
|
236
|
-
// survives a transient state-fetch error.
|
|
260
|
+
// Device-scoped socket: bearer when authenticated, else deviceId+deviceSecret so
|
|
261
|
+
// signed-out tabs still receive `session_state` and can mint on change.
|
|
237
262
|
if (this.host.getAccessToken()) {
|
|
238
263
|
try {
|
|
239
264
|
await this.bootstrap();
|
|
@@ -275,24 +300,41 @@ export class SessionClient {
|
|
|
275
300
|
return;
|
|
276
301
|
}
|
|
277
302
|
if (!this.started) return; // stopped while the dynamic import was in flight
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
303
|
+
|
|
304
|
+
const token = this.host.getAccessToken();
|
|
305
|
+
const deviceCredential = this.host.getDeviceCredential();
|
|
306
|
+
if (!token && !deviceCredential) return;
|
|
281
307
|
|
|
282
308
|
const socket = io(this.host.getBaseURL(), {
|
|
283
309
|
transports: ['websocket'],
|
|
284
310
|
autoConnect: true,
|
|
285
|
-
|
|
286
|
-
|
|
311
|
+
reconnection: true,
|
|
312
|
+
reconnectionAttempts: Infinity,
|
|
313
|
+
reconnectionDelay: 1000,
|
|
314
|
+
reconnectionDelayMax: 10000,
|
|
315
|
+
auth: (cb: (data: Record<string, string>) => void) => {
|
|
316
|
+
const bearer = this.host.getAccessToken();
|
|
317
|
+
if (bearer) {
|
|
318
|
+
cb({ token: bearer });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const cred = this.host.getDeviceCredential();
|
|
322
|
+
if (cred) {
|
|
323
|
+
cb({ deviceId: cred.deviceId, deviceSecret: cred.deviceSecret });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
cb({ token: '' });
|
|
287
327
|
},
|
|
288
328
|
});
|
|
289
329
|
socket.on('session_state', (payload: unknown) => {
|
|
290
330
|
const applied = this.applyState(payload);
|
|
291
331
|
if (!applied) return;
|
|
292
332
|
// A push changed the active account on another device/tab — re-fetch state
|
|
293
|
-
// to plant the access token for the newly-active account.
|
|
333
|
+
// to plant the access token for the newly-active account. When this tab is
|
|
334
|
+
// still signed out, applyState mints via ensureActiveToken first; bootstrap
|
|
335
|
+
// requires a bearer and must not run until then.
|
|
294
336
|
const active = this.state?.activeAccountId ?? null;
|
|
295
|
-
if (active && active !== this.host.getCurrentAccountId()) {
|
|
337
|
+
if (active && active !== this.host.getCurrentAccountId() && this.host.getAccessToken()) {
|
|
296
338
|
void this.bootstrap().catch((error) => {
|
|
297
339
|
logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
|
|
298
340
|
});
|
|
@@ -16,6 +16,7 @@ function makeHost(makeRequest: jest.Mock, currentAccountId: string | null = null
|
|
|
16
16
|
makeRequest,
|
|
17
17
|
getBaseURL: () => 'http://test.invalid',
|
|
18
18
|
getAccessToken: () => 't',
|
|
19
|
+
getDeviceCredential: () => null,
|
|
19
20
|
onTokensChanged: () => () => undefined,
|
|
20
21
|
setTokens: jest.fn(),
|
|
21
22
|
getCurrentAccountId: () => currentAccountId,
|
|
@@ -55,6 +55,7 @@ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
|
55
55
|
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
56
56
|
getBaseURL: () => 'http://test.invalid',
|
|
57
57
|
getAccessToken: () => 'tok',
|
|
58
|
+
getDeviceCredential: () => null,
|
|
58
59
|
onTokensChanged: () => () => undefined,
|
|
59
60
|
setTokens: jest.fn(),
|
|
60
61
|
getCurrentAccountId: () => 'a1',
|
|
@@ -11,6 +11,7 @@ function makeHost(makeRequest: jest.Mock): SessionClientHost {
|
|
|
11
11
|
makeRequest,
|
|
12
12
|
getBaseURL: () => 'http://test.invalid',
|
|
13
13
|
getAccessToken: () => 't',
|
|
14
|
+
getDeviceCredential: () => null,
|
|
14
15
|
onTokensChanged: () => () => undefined,
|
|
15
16
|
setTokens: jest.fn(),
|
|
16
17
|
getCurrentAccountId: () => null,
|
|
@@ -10,6 +10,7 @@ function makeHost(makeRequest: jest.Mock): SessionClientHost {
|
|
|
10
10
|
makeRequest,
|
|
11
11
|
getBaseURL: () => 'http://test.invalid',
|
|
12
12
|
getAccessToken: () => 't',
|
|
13
|
+
getDeviceCredential: () => null,
|
|
13
14
|
onTokensChanged: () => () => undefined,
|
|
14
15
|
setTokens: jest.fn(),
|
|
15
16
|
getCurrentAccountId: () => null,
|
|
@@ -30,6 +30,7 @@ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
|
30
30
|
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
31
31
|
getBaseURL: () => 'http://test.invalid',
|
|
32
32
|
getAccessToken: () => 'tok',
|
|
33
|
+
getDeviceCredential: () => null,
|
|
33
34
|
onTokensChanged: () => () => undefined,
|
|
34
35
|
setTokens: jest.fn(),
|
|
35
36
|
getCurrentAccountId: () => 'a1',
|
|
@@ -32,6 +32,7 @@ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
|
32
32
|
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
33
33
|
getBaseURL: () => 'http://test.invalid',
|
|
34
34
|
getAccessToken: () => 'tok',
|
|
35
|
+
getDeviceCredential: () => null,
|
|
35
36
|
onTokensChanged: () => () => undefined,
|
|
36
37
|
setTokens: jest.fn(),
|
|
37
38
|
getCurrentAccountId: () => 'a1',
|
|
@@ -6,6 +6,7 @@ function makeHost(): SessionClientHost {
|
|
|
6
6
|
makeRequest: jest.fn(),
|
|
7
7
|
getBaseURL: () => 'http://test.invalid',
|
|
8
8
|
getAccessToken: () => 't',
|
|
9
|
+
getDeviceCredential: () => null,
|
|
9
10
|
onTokensChanged: () => () => undefined,
|
|
10
11
|
setTokens: jest.fn(),
|
|
11
12
|
getCurrentAccountId: () => null,
|
|
@@ -22,6 +22,7 @@ function host(): SessionClientHost {
|
|
|
22
22
|
makeRequest: jest.fn(),
|
|
23
23
|
getBaseURL: () => 'http://test.invalid',
|
|
24
24
|
getAccessToken: () => 'token',
|
|
25
|
+
getDeviceCredential: () => null,
|
|
25
26
|
onTokensChanged: () => () => undefined,
|
|
26
27
|
setTokens: jest.fn(),
|
|
27
28
|
getCurrentAccountId: () => null,
|
|
@@ -533,7 +534,23 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
533
534
|
});
|
|
534
535
|
|
|
535
536
|
describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
536
|
-
|
|
537
|
+
beforeEach(() => {
|
|
538
|
+
const store = new Map<string, string>();
|
|
539
|
+
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
540
|
+
value: {
|
|
541
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
542
|
+
setItem: (key: string, value: string) => {
|
|
543
|
+
store.set(key, value);
|
|
544
|
+
},
|
|
545
|
+
removeItem: (key: string) => {
|
|
546
|
+
store.delete(key);
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
configurable: true,
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', async () => {
|
|
537
554
|
const oxy = makeOxy();
|
|
538
555
|
const sc = new TestSessionClient(host());
|
|
539
556
|
const openUrl = jest.fn();
|
|
@@ -544,17 +561,32 @@ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
|
544
561
|
openUrl,
|
|
545
562
|
});
|
|
546
563
|
|
|
547
|
-
const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/'
|
|
564
|
+
const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/dashboard' });
|
|
548
565
|
const parsed = new URL(url);
|
|
549
566
|
expect(parsed.origin).toBe('https://auth.oxy.so');
|
|
550
567
|
expect(parsed.pathname).toBe('/login');
|
|
551
|
-
expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth
|
|
568
|
+
expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth');
|
|
552
569
|
expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
|
|
553
|
-
expect(parsed.searchParams.get('state')).
|
|
570
|
+
expect(parsed.searchParams.get('state')).toBeTruthy();
|
|
571
|
+
expect(parsed.searchParams.get('code_challenge')).toBeTruthy();
|
|
572
|
+
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
|
554
573
|
expect(openUrl).toHaveBeenCalledWith(url);
|
|
555
574
|
});
|
|
556
575
|
|
|
557
|
-
it('honors
|
|
576
|
+
it('honors authRedirectUri over returnUrl', async () => {
|
|
577
|
+
const oxy = makeOxy();
|
|
578
|
+
const sc = new TestSessionClient(host());
|
|
579
|
+
const controller = new AccountDialogController({
|
|
580
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
581
|
+
sessionClient: sc,
|
|
582
|
+
authRedirectUri: 'https://inbox.oxy.so',
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://inbox.oxy.so/mail' });
|
|
586
|
+
expect(new URL(url).searchParams.get('redirect_uri')).toBe('https://inbox.oxy.so');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('honors an idpApex override', async () => {
|
|
558
590
|
const oxy = makeOxy();
|
|
559
591
|
const sc = new TestSessionClient(host());
|
|
560
592
|
const controller = new AccountDialogController({
|
|
@@ -562,7 +594,7 @@ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
|
562
594
|
sessionClient: sc,
|
|
563
595
|
idpApex: 'alia.onl',
|
|
564
596
|
});
|
|
565
|
-
const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
|
|
597
|
+
const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
|
|
566
598
|
expect(new URL(url).origin).toBe('https://auth.alia.onl');
|
|
567
599
|
});
|
|
568
600
|
});
|
|
@@ -44,6 +44,13 @@ describe('createSessionClientHost', () => {
|
|
|
44
44
|
expect(host.getCurrentAccountId()).toBe('u1');
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
test('setDeviceCredential reflects on getDeviceCredential', () => {
|
|
48
|
+
const host = createSessionClientHost(fakeOxy() as never);
|
|
49
|
+
expect(host.getDeviceCredential()).toBeNull();
|
|
50
|
+
host.setDeviceCredential({ deviceId: 'd1', deviceSecret: 's1' });
|
|
51
|
+
expect(host.getDeviceCredential()).toEqual({ deviceId: 'd1', deviceSecret: 's1' });
|
|
52
|
+
});
|
|
53
|
+
|
|
47
54
|
test('onTokensChanged forwards to oxyServices and unsubscribes', () => {
|
|
48
55
|
const oxy = fakeOxy();
|
|
49
56
|
const host = createSessionClientHost(oxy as never);
|
|
@@ -31,6 +31,12 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
|
31
31
|
import type { User } from '../models/interfaces';
|
|
32
32
|
import { logger } from '../utils/loggerUtils';
|
|
33
33
|
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
|
|
34
|
+
import {
|
|
35
|
+
generateOAuthState,
|
|
36
|
+
generatePkcePair,
|
|
37
|
+
normalizeOAuthRedirectUri,
|
|
38
|
+
persistOAuthHandshake,
|
|
39
|
+
} from '../utils/oauthPkce';
|
|
34
40
|
import { SessionClient } from './SessionClient';
|
|
35
41
|
import {
|
|
36
42
|
projectSwitchableAccounts,
|
|
@@ -111,6 +117,12 @@ export interface AccountDialogControllerOptions {
|
|
|
111
117
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
112
118
|
/** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
|
|
113
119
|
idpApex?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Registered OAuth redirect URI for this RP (exact match against
|
|
122
|
+
* `Application.redirectUris`). When set, wins over `returnUrl` /
|
|
123
|
+
* `location.origin` normalization in {@link openPasswordAtOxyAuth}.
|
|
124
|
+
*/
|
|
125
|
+
authRedirectUri?: string | null;
|
|
114
126
|
/** QR device-flow poll interval in ms (default 3000). */
|
|
115
127
|
pollIntervalMs?: number;
|
|
116
128
|
/**
|
|
@@ -145,6 +157,7 @@ export class AccountDialogController {
|
|
|
145
157
|
private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
146
158
|
private readonly onSignedIn?: (user: MinimalUserData) => void;
|
|
147
159
|
private readonly idpApex: string;
|
|
160
|
+
private readonly authRedirectUri: string | null;
|
|
148
161
|
private readonly pollIntervalMs: number;
|
|
149
162
|
private readonly openUrl?: (url: string) => void;
|
|
150
163
|
|
|
@@ -181,6 +194,7 @@ export class AccountDialogController {
|
|
|
181
194
|
this.commitSession = options.commitSession;
|
|
182
195
|
this.onSignedIn = options.onSignedIn;
|
|
183
196
|
this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
|
|
197
|
+
this.authRedirectUri = options.authRedirectUri ?? null;
|
|
184
198
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
185
199
|
this.openUrl = options.openUrl;
|
|
186
200
|
this.snapshot = this.computeSnapshot();
|
|
@@ -548,18 +562,30 @@ export class AccountDialogController {
|
|
|
548
562
|
* @param params.state - Optional opaque state echoed back on return.
|
|
549
563
|
* @returns The absolute auth.oxy.so sign-in URL.
|
|
550
564
|
*/
|
|
551
|
-
openPasswordAtOxyAuth(
|
|
565
|
+
async openPasswordAtOxyAuth(
|
|
566
|
+
params: { returnUrl?: string; state?: string; redirectUri?: string } = {},
|
|
567
|
+
): Promise<string> {
|
|
552
568
|
const base = `https://auth.${this.idpApex}`;
|
|
553
569
|
const url = new URL('/login', base);
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
570
|
+
const rawRedirect =
|
|
571
|
+
params.redirectUri ??
|
|
572
|
+
this.authRedirectUri ??
|
|
573
|
+
params.returnUrl ??
|
|
574
|
+
currentLocationOrigin();
|
|
575
|
+
const redirectUri = rawRedirect ? normalizeOAuthRedirectUri(rawRedirect) : '';
|
|
576
|
+
if (redirectUri) {
|
|
577
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
557
578
|
}
|
|
558
579
|
if (this.clientId) {
|
|
559
580
|
url.searchParams.set('client_id', this.clientId);
|
|
560
581
|
}
|
|
561
|
-
|
|
562
|
-
|
|
582
|
+
const state = params.state ?? (await generateOAuthState());
|
|
583
|
+
url.searchParams.set('state', state);
|
|
584
|
+
const { codeChallenge, codeVerifier } = await generatePkcePair();
|
|
585
|
+
url.searchParams.set('code_challenge', codeChallenge);
|
|
586
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
587
|
+
if (!persistOAuthHandshake(state, codeVerifier)) {
|
|
588
|
+
throw new Error('Could not persist OAuth handshake for password sign-in');
|
|
563
589
|
}
|
|
564
590
|
const href = url.toString();
|
|
565
591
|
this.openUrl?.(href);
|
|
@@ -748,8 +774,8 @@ export function createAccountDialogController(
|
|
|
748
774
|
// Local helpers
|
|
749
775
|
// ---------------------------------------------------------------------------
|
|
750
776
|
|
|
751
|
-
/** Current document
|
|
752
|
-
function
|
|
753
|
-
const location = (globalThis as { location?: {
|
|
754
|
-
return typeof location?.
|
|
777
|
+
/** Current document origin on web; empty string where `location` is absent (native/SSR). */
|
|
778
|
+
function currentLocationOrigin(): string {
|
|
779
|
+
const location = (globalThis as { location?: { origin?: string } }).location;
|
|
780
|
+
return typeof location?.origin === 'string' ? location.origin : '';
|
|
755
781
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* a reload restores the session locally without a redirect: `deviceId` +
|
|
6
6
|
* `deviceSecret` mint a fresh access token via `POST /session/device/token`.
|
|
7
7
|
* This module is the storage seam: a tiny `load / save / clear` interface plus
|
|
8
|
-
* platform factories, so the cold boot (`
|
|
8
|
+
* platform factories, so the cold boot (`sessionColdBoot`) and the unified re-mint
|
|
9
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
|
|
@@ -106,17 +106,26 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
const candidate = parsed as Record<string, unknown>;
|
|
109
|
-
|
|
110
|
-
typeof candidate.
|
|
111
|
-
|
|
112
|
-
candidate.
|
|
113
|
-
candidate.
|
|
114
|
-
|
|
109
|
+
const hasDeviceCredential =
|
|
110
|
+
typeof candidate.deviceId === 'string' &&
|
|
111
|
+
candidate.deviceId.length > 0 &&
|
|
112
|
+
typeof candidate.deviceSecret === 'string' &&
|
|
113
|
+
candidate.deviceSecret.length > 0;
|
|
114
|
+
|
|
115
|
+
const hasSessionIdentity =
|
|
116
|
+
typeof candidate.sessionId === 'string' &&
|
|
117
|
+
typeof candidate.userId === 'string' &&
|
|
118
|
+
candidate.sessionId.length > 0 &&
|
|
119
|
+
candidate.userId.length > 0;
|
|
120
|
+
|
|
121
|
+
// Device-only bootstrap (post join, pre-sign-in): durable credential without session.
|
|
122
|
+
if (!hasSessionIdentity && !hasDeviceCredential) {
|
|
115
123
|
return null;
|
|
116
124
|
}
|
|
125
|
+
|
|
117
126
|
const state: PersistedAuthState = {
|
|
118
|
-
sessionId: candidate.sessionId,
|
|
119
|
-
userId: candidate.userId,
|
|
127
|
+
sessionId: hasSessionIdentity ? (candidate.sessionId as string) : '',
|
|
128
|
+
userId: hasSessionIdentity ? (candidate.userId as string) : '',
|
|
120
129
|
};
|
|
121
130
|
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
122
131
|
state.deviceId = candidate.deviceId;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-sign-in hub sync — plant device credentials on auth.oxy.so via a
|
|
3
|
+
* one-time server ticket (no secrets in URL fragments).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { OxyServices } from '../OxyServices';
|
|
7
|
+
import type { AuthStateStore } from './authStateStore';
|
|
8
|
+
import {
|
|
9
|
+
buildHubSyncUrl,
|
|
10
|
+
buildIdpHubOrigin,
|
|
11
|
+
isIdpHubOrigin,
|
|
12
|
+
isOfficialWebOrigin,
|
|
13
|
+
} from '../utils/officialOrigins';
|
|
14
|
+
|
|
15
|
+
export interface SyncHubAfterSignInOptions {
|
|
16
|
+
/** Skip sync when false (OxyProvider hubSync prop). @default true */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* After a successful sign-in on an official web app, mint a hub ticket and
|
|
22
|
+
* redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
|
|
23
|
+
* shared device credential for silent OAuth restore on other origins.
|
|
24
|
+
*
|
|
25
|
+
* No-op on native, non-official origins, and when already on the IdP hub.
|
|
26
|
+
*/
|
|
27
|
+
export async function syncHubAfterSignIn(
|
|
28
|
+
oxy: Pick<OxyServices, 'issueHubTicket'>,
|
|
29
|
+
opts?: SyncHubAfterSignInOptions,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
if (opts?.enabled === false) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof globalThis === 'undefined') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const location = (globalThis as { location?: Location }).location;
|
|
40
|
+
if (!location) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isIdpHubOrigin()) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!isOfficialWebOrigin(location.origin)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hubOrigin = buildIdpHubOrigin();
|
|
53
|
+
const issued = await oxy.issueHubTicket(hubOrigin);
|
|
54
|
+
const returnUrl = `${location.origin}${location.pathname}${location.search}`;
|
|
55
|
+
const syncUrl = buildHubSyncUrl(issued.ticket, returnUrl);
|
|
56
|
+
|
|
57
|
+
window.location.assign(syncUrl);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
|
|
62
|
+
export async function redeemHubTicketOnHub(
|
|
63
|
+
oxy: Pick<OxyServices, 'redeemHubTicket'>,
|
|
64
|
+
store: AuthStateStore,
|
|
65
|
+
ticket: string,
|
|
66
|
+
): Promise<boolean> {
|
|
67
|
+
const hubOrigin = buildIdpHubOrigin();
|
|
68
|
+
const creds = await oxy.redeemHubTicket(ticket, hubOrigin);
|
|
69
|
+
const prior = await store.load();
|
|
70
|
+
await store.save({
|
|
71
|
+
sessionId: prior?.sessionId ?? '',
|
|
72
|
+
userId: prior?.userId ?? '',
|
|
73
|
+
deviceId: creds.deviceId,
|
|
74
|
+
deviceSecret: creds.deviceSecret,
|
|
75
|
+
...(prior?.accessToken ? { accessToken: prior.accessToken } : {}),
|
|
76
|
+
...(prior?.expiresAt ? { expiresAt: prior.expiresAt } : {}),
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
|
-
import type { SessionClientHost } from './SessionClient';
|
|
2
|
+
import type { DeviceCredential, SessionClientHost } from './SessionClient';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Thin `SessionClientHost` adapter over an `OxyServices` instance.
|
|
@@ -15,17 +15,25 @@ import type { SessionClientHost } from './SessionClient';
|
|
|
15
15
|
*/
|
|
16
16
|
export function createSessionClientHost(
|
|
17
17
|
oxyServices: OxyServices,
|
|
18
|
-
): SessionClientHost & {
|
|
18
|
+
): SessionClientHost & {
|
|
19
|
+
setCurrentAccountId(id: string | null): void;
|
|
20
|
+
setDeviceCredential(credential: DeviceCredential | null): void;
|
|
21
|
+
} {
|
|
19
22
|
let currentAccountId: string | null = null;
|
|
23
|
+
let deviceCredential: DeviceCredential | null = null;
|
|
20
24
|
return {
|
|
21
25
|
makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
|
|
22
26
|
getBaseURL: () => oxyServices.getBaseURL(),
|
|
23
27
|
getAccessToken: () => oxyServices.getAccessToken(),
|
|
28
|
+
getDeviceCredential: () => deviceCredential,
|
|
24
29
|
onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
|
|
25
30
|
setTokens: (accessToken) => oxyServices.setTokens(accessToken),
|
|
26
31
|
getCurrentAccountId: () => currentAccountId,
|
|
27
32
|
setCurrentAccountId: (id) => {
|
|
28
33
|
currentAccountId = id;
|
|
29
34
|
},
|
|
35
|
+
setDeviceCredential: (credential) => {
|
|
36
|
+
deviceCredential = credential;
|
|
37
|
+
},
|
|
30
38
|
};
|
|
31
39
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildHubSyncUrl,
|
|
3
|
+
buildIdpHubOrigin,
|
|
4
|
+
isAllowedDeviceJoinOrigin,
|
|
5
|
+
isIdpHubOrigin,
|
|
6
|
+
isOfficialWebOrigin,
|
|
7
|
+
normalizeOfficialReturnOrigin,
|
|
8
|
+
parseHubSyncReturnUrl,
|
|
9
|
+
} from '../officialOrigins';
|
|
10
|
+
|
|
11
|
+
describe('officialOrigins', () => {
|
|
12
|
+
it('builds the IdP hub origin', () => {
|
|
13
|
+
expect(buildIdpHubOrigin()).toBe('https://auth.oxy.so');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('allows official first-party origins', () => {
|
|
17
|
+
expect(isOfficialWebOrigin('https://inbox.oxy.so')).toBe(true);
|
|
18
|
+
expect(isOfficialWebOrigin('https://mention.earth')).toBe(true);
|
|
19
|
+
expect(isOfficialWebOrigin('https://evil.example')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps the deprecated alias in sync with isOfficialWebOrigin', () => {
|
|
23
|
+
expect(isAllowedDeviceJoinOrigin('https://accounts.oxy.so')).toBe(true);
|
|
24
|
+
expect(isAllowedDeviceJoinOrigin('https://evil.example')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('normalizes return origins to origin only', () => {
|
|
28
|
+
expect(normalizeOfficialReturnOrigin('https://accounts.oxy.so/settings')).toBe(
|
|
29
|
+
'https://accounts.oxy.so',
|
|
30
|
+
);
|
|
31
|
+
expect(normalizeOfficialReturnOrigin('https://evil.example/')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('parses hub-sync return URLs', () => {
|
|
35
|
+
expect(parseHubSyncReturnUrl('https://inbox.oxy.so/messages')).toBe(
|
|
36
|
+
'https://inbox.oxy.so/messages',
|
|
37
|
+
);
|
|
38
|
+
expect(parseHubSyncReturnUrl('https://evil.example/')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('builds hub sync URLs with ticket and optional return', () => {
|
|
42
|
+
const url = new URL(buildHubSyncUrl('tk-abc', 'https://accounts.oxy.so/'));
|
|
43
|
+
expect(url.pathname).toBe('/sync');
|
|
44
|
+
expect(url.searchParams.get('ticket')).toBe('tk-abc');
|
|
45
|
+
expect(url.searchParams.get('return')).toBe('https://accounts.oxy.so/');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('isIdpHubOrigin', () => {
|
|
49
|
+
const originalLocation = globalThis.location;
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
Object.defineProperty(globalThis, 'location', {
|
|
53
|
+
configurable: true,
|
|
54
|
+
value: originalLocation,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns true on auth.oxy.so', () => {
|
|
59
|
+
Object.defineProperty(globalThis, 'location', {
|
|
60
|
+
configurable: true,
|
|
61
|
+
value: { href: 'https://auth.oxy.so/sync' },
|
|
62
|
+
});
|
|
63
|
+
expect(isIdpHubOrigin()).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns false on satellite origins', () => {
|
|
67
|
+
Object.defineProperty(globalThis, 'location', {
|
|
68
|
+
configurable: true,
|
|
69
|
+
value: { href: 'https://inbox.oxy.so/' },
|
|
70
|
+
});
|
|
71
|
+
expect(isIdpHubOrigin()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/utils/coldBoot.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* web bundle — the reason any run-once guard for a step must live in the
|
|
17
17
|
* calling consumer, never in a core module-level singleton).
|
|
18
18
|
* - Architecture-agnostic: it knows nothing about HOW a step resolves a
|
|
19
|
-
* session; `runSessionColdBoot` (`boot/
|
|
19
|
+
* session; `runSessionColdBoot` (`boot/sessionColdBoot.ts`) is the current
|
|
20
20
|
* device-first consumer.
|
|
21
21
|
*
|
|
22
22
|
* A step is skipped (without running) when its `enabled` predicate returns
|
|
@@ -106,7 +106,7 @@ export interface RunColdBootOptions<S> {
|
|
|
106
106
|
* fails to settle before the deadline, the runner abandons the await for that
|
|
107
107
|
* step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
|
|
108
108
|
* each now racing against an already-expired deadline. This is deliberate:
|
|
109
|
-
* the runner keeps iterating so the TERMINAL step (e.g. `
|
|
109
|
+
* the runner keeps iterating so the TERMINAL step (e.g. `sessionColdBoot`'s
|
|
110
110
|
* `bootstrap-hop`, whose `run()` performs its navigation side effect
|
|
111
111
|
* synchronously before its first `await`) still gets to fire. A step that
|
|
112
112
|
* has nothing to contribute after the deadline simply doesn't settle and is
|