@oxy.so/services 11.0.0 → 11.2.0-oxy-one.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/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +14 -1
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxySignInButton.js +25 -7
- package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +198 -41
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/commitSessionFlow.js +5 -0
- package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +56 -9
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/isolatedOAuthSession.js +52 -0
- package/lib/commonjs/ui/context/isolatedOAuthSession.js.map +1 -0
- package/lib/commonjs/ui/context/useOxyAccountGraph.js +7 -5
- package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/usePersonalPlans.js +43 -0
- package/lib/commonjs/ui/hooks/queries/usePersonalPlans.js.map +1 -0
- package/lib/commonjs/ui/hooks/useAuth.js +7 -3
- package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
- package/lib/commonjs/ui/oauth/applicationSessionLane.js +32 -0
- package/lib/commonjs/ui/oauth/applicationSessionLane.js.map +1 -0
- package/lib/commonjs/ui/oauth/completeOAuthCode.js +4 -2
- package/lib/commonjs/ui/oauth/completeOAuthCode.js.map +1 -1
- package/lib/commonjs/ui/oauth/nativeAuthTransport.js +119 -0
- package/lib/commonjs/ui/oauth/nativeAuthTransport.js.map +1 -0
- package/lib/commonjs/ui/session/createSessionClient.js +14 -2
- package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
- package/lib/commonjs/ui/session/deviceCredentialTracker.js +8 -0
- package/lib/commonjs/ui/session/deviceCredentialTracker.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +14 -1
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/OxySignInButton.js +25 -7
- package/lib/module/ui/components/OxySignInButton.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +200 -43
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/commitSessionFlow.js +5 -0
- package/lib/module/ui/context/commitSessionFlow.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +56 -9
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/isolatedOAuthSession.js +44 -0
- package/lib/module/ui/context/isolatedOAuthSession.js.map +1 -0
- package/lib/module/ui/context/useOxyAccountGraph.js +7 -5
- package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/module/ui/hooks/queries/usePersonalPlans.js +39 -0
- package/lib/module/ui/hooks/queries/usePersonalPlans.js.map +1 -0
- package/lib/module/ui/hooks/useAuth.js +7 -3
- package/lib/module/ui/hooks/useAuth.js.map +1 -1
- package/lib/module/ui/oauth/applicationSessionLane.js +26 -0
- package/lib/module/ui/oauth/applicationSessionLane.js.map +1 -0
- package/lib/module/ui/oauth/completeOAuthCode.js +4 -2
- package/lib/module/ui/oauth/completeOAuthCode.js.map +1 -1
- package/lib/module/ui/oauth/nativeAuthTransport.js +115 -0
- package/lib/module/ui/oauth/nativeAuthTransport.js.map +1 -0
- package/lib/module/ui/session/createSessionClient.js +14 -2
- package/lib/module/ui/session/createSessionClient.js.map +1 -1
- package/lib/module/ui/session/deviceCredentialTracker.js +8 -0
- package/lib/module/ui/session/deviceCredentialTracker.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +7 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +2 -0
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +3 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts +2 -0
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +3 -2
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/isolatedOAuthSession.d.ts +19 -0
- package/lib/typescript/commonjs/ui/context/isolatedOAuthSession.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +3 -0
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts +2 -1
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +6 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +64 -0
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/oauth/applicationSessionLane.d.ts +6 -0
- package/lib/typescript/commonjs/ui/oauth/applicationSessionLane.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/oauth/completeOAuthCode.d.ts +2 -2
- package/lib/typescript/commonjs/ui/oauth/nativeAuthTransport.d.ts +28 -0
- package/lib/typescript/commonjs/ui/oauth/nativeAuthTransport.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +1 -1
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/deviceCredentialTracker.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +7 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts +2 -0
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +3 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts +2 -0
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +3 -2
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/isolatedOAuthSession.d.ts +19 -0
- package/lib/typescript/module/ui/context/isolatedOAuthSession.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +3 -0
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts +2 -1
- package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +6 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +64 -0
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
- package/lib/typescript/module/ui/oauth/applicationSessionLane.d.ts +6 -0
- package/lib/typescript/module/ui/oauth/applicationSessionLane.d.ts.map +1 -0
- package/lib/typescript/module/ui/oauth/completeOAuthCode.d.ts +2 -2
- package/lib/typescript/module/ui/oauth/nativeAuthTransport.d.ts +28 -0
- package/lib/typescript/module/ui/oauth/nativeAuthTransport.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/createSessionClient.d.ts +1 -1
- package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/deviceCredentialTracker.d.ts.map +1 -1
- package/package.json +30 -30
- package/src/index.ts +4 -0
- package/src/ui/boot/__tests__/coldBootNoIdpRedirect.test.ts +56 -2
- package/src/ui/boot/runProviderColdBoot.ts +22 -2
- package/src/ui/components/OxySignInButton.tsx +20 -4
- package/src/ui/context/OxyContext.tsx +171 -40
- package/src/ui/context/__tests__/commitSessionFlow.test.ts +13 -0
- package/src/ui/context/__tests__/isolatedOAuthSession.test.ts +35 -0
- package/src/ui/context/commitSessionFlow.ts +8 -0
- package/src/ui/context/hooks/useAuthOperations.ts +55 -9
- package/src/ui/context/isolatedOAuthSession.ts +48 -0
- package/src/ui/context/oxyContextTypes.ts +4 -0
- package/src/ui/context/useOxyAccountGraph.ts +8 -5
- package/src/ui/hooks/queries/usePersonalPlans.ts +23 -0
- package/src/ui/hooks/useAuth.ts +6 -2
- package/src/ui/oauth/__tests__/completeOAuthCode.test.ts +1 -1
- package/src/ui/oauth/__tests__/nativeAuthTransport.test.ts +57 -0
- package/src/ui/oauth/__tests__/oauthBearerCommit.http.test.ts +131 -0
- package/src/ui/oauth/applicationSessionLane.ts +26 -0
- package/src/ui/oauth/completeOAuthCode.ts +3 -3
- package/src/ui/oauth/nativeAuthTransport.ts +67 -0
- package/src/ui/session/createSessionClient.ts +13 -1
- package/src/ui/session/deviceCredentialTracker.ts +7 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { startNativeOAuthSignIn, type StartNativeOAuthSignInOptions } from '../oauth/nativeAuthTransport';
|
|
2
|
+
import { clearIsolatedOAuthSession, hasIsolatedOAuthSession, readIsolatedOAuthSession, setIsolatedOAuthSession } from './isolatedOAuthSession';
|
|
1
3
|
import type React from 'react';
|
|
2
4
|
import {
|
|
3
5
|
createContext,
|
|
@@ -9,13 +11,13 @@ import {
|
|
|
9
11
|
useState,
|
|
10
12
|
useSyncExternalStore,
|
|
11
13
|
} from 'react';
|
|
12
|
-
import { Linking, Platform } from 'react-native';
|
|
14
|
+
import { AppState, Linking, Platform } from 'react-native';
|
|
13
15
|
import { OxyServices } from '@oxy.so/core';
|
|
14
16
|
import type { LoginSessionResult } from '@oxy.so/contracts';
|
|
15
17
|
import type { User, SessionLoginResponse } from '@oxy.so/core';
|
|
16
18
|
import type { AuthStateStore, PersistedAuthState, AccountDialogController, AccountDialogView } from '@oxy.so/core/session';
|
|
17
19
|
import { KeyManager } from '@oxy.so/core/crypto';
|
|
18
|
-
import { refreshDeviceSecretArm, establishIdentitySession,
|
|
20
|
+
import { refreshDeviceSecretArm, establishIdentitySession, createAuthRefreshHandler, startTokenRefreshScheduler, createAccountDialogController } from '@oxy.so/core/session';
|
|
19
21
|
import { logger as loggerUtil } from '@oxy.so/core';
|
|
20
22
|
import {
|
|
21
23
|
registerAccountDialogControls,
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
} from '../oauth/browserAuthTransport';
|
|
29
31
|
import type { WebOAuthSignInResult } from '../oauth/types';
|
|
30
32
|
import { openBridgeWindow, resolveBridgeOrigin, runBrowserBridge } from '../oauth/browserBridge';
|
|
33
|
+
import { createPlatformSharedDeviceCredentialStore } from '../session/sharedDeviceCredentialStore';
|
|
31
34
|
import { trackDeviceCredential, type CredentialTrackingAuthStateStore } from '../session/deviceCredentialTracker';
|
|
32
35
|
import {
|
|
33
36
|
requestOAuthConsent,
|
|
@@ -37,6 +40,8 @@ import {
|
|
|
37
40
|
import { isWebBrowser } from '../utils/isWebBrowser';
|
|
38
41
|
import { resolveDeliveryPlatform } from '../utils/deliveryPlatform';
|
|
39
42
|
import { runProviderColdBoot } from '../boot/runProviderColdBoot';
|
|
43
|
+
import { resolveApplicationSessionLane } from '../oauth/applicationSessionLane';
|
|
44
|
+
import { handleAuthError } from '../utils/errorHandlers';
|
|
40
45
|
import { hasPersistedSessionCredential, loadPersistedDeviceCredential } from '../utils/deviceCredential';
|
|
41
46
|
import { createTokenLossRecovery } from '../session/tokenLossRecovery';
|
|
42
47
|
import { bindAuthStoreToRuntime } from '../stores/authStore';
|
|
@@ -111,7 +116,7 @@ type OxyAuthActionsContextState = Pick<
|
|
|
111
116
|
| 'showBottomSheet'
|
|
112
117
|
| 'openAvatarPicker'
|
|
113
118
|
| 'openAccountDialog'
|
|
114
|
-
|
|
119
|
+
> & { ensureDeviceSessionLane: () => void | Promise<void> };
|
|
115
120
|
|
|
116
121
|
const OxyAuthActionsContext = createContext<OxyAuthActionsContextState | null>(null);
|
|
117
122
|
|
|
@@ -196,11 +201,17 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
196
201
|
// Server-authoritative device session client. Built ONCE per `oxyServices`
|
|
197
202
|
// instance. Device-scoped sockets connect with deviceId+deviceSecret when no
|
|
198
203
|
// bearer is planted yet, so cross-origin apps sync via `session_state`.
|
|
204
|
+
const runtimeRef = useRef<OxyRuntime | null>(null);
|
|
205
|
+
const registeredLaneRef = useRef<{ clientId: string | undefined; services: OxyServices; lane: 'device' | 'oauth' | null; pending: Promise<'device' | 'oauth'> | null }>({ clientId: clientIdProp, services: oxyServices, lane: null, pending: null });
|
|
206
|
+
if (registeredLaneRef.current.clientId !== clientIdProp || registeredLaneRef.current.services !== oxyServices) {
|
|
207
|
+
registeredLaneRef.current = { clientId: clientIdProp, services: oxyServices, lane: null, pending: null };
|
|
208
|
+
}
|
|
199
209
|
const sessionClientPairRef = useRef<ReturnType<typeof createSessionClient> | null>(null);
|
|
200
210
|
if (!sessionClientPairRef.current) {
|
|
201
211
|
sessionClientPairRef.current = createSessionClient(
|
|
202
212
|
oxyServices,
|
|
203
213
|
(origin) => {
|
|
214
|
+
if (runtimeRef.current && hasIsolatedOAuthSession(runtimeRef.current)) return;
|
|
204
215
|
// Erase the DURABLE device credential only on a `request`-origin verdict
|
|
205
216
|
// (a REST sign-out / revocation). A `push`-origin empty state is a socket
|
|
206
217
|
// broadcast that may be a transient reconnect artifact — clearing the
|
|
@@ -219,6 +230,11 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
219
230
|
// resolved its pin. Read through the ref so the resolver stays stable for
|
|
220
231
|
// the client's lifetime while still seeing later resolutions.
|
|
221
232
|
() => identityRef.current?.getPinnedAccountId() ?? null,
|
|
233
|
+
() => registeredLaneRef.current.lane === 'device' && (!runtimeRef.current || !hasIsolatedOAuthSession(runtimeRef.current)),
|
|
234
|
+
async () => {
|
|
235
|
+
if (identityRef.current || registeredLaneRef.current.lane !== 'device') return;
|
|
236
|
+
if (await authStore.setAutomaticIdentitySignInSuppressed?.(true) === false) throw new Error('Failed to persist explicit sign-out intent');
|
|
237
|
+
},
|
|
222
238
|
);
|
|
223
239
|
}
|
|
224
240
|
const { client: sessionClient, host: sessionClientHost } = sessionClientPairRef.current;
|
|
@@ -239,7 +255,6 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
239
255
|
() => undefined,
|
|
240
256
|
);
|
|
241
257
|
|
|
242
|
-
const runtimeRef = useRef<OxyRuntime | null>(null);
|
|
243
258
|
if (!runtimeRef.current) {
|
|
244
259
|
runtimeRef.current = createOxyRuntime({
|
|
245
260
|
oxyServices,
|
|
@@ -480,7 +495,49 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
480
495
|
sessionClientHost.setDeviceCredential(cred);
|
|
481
496
|
}, [authStore, sessionClientHost]);
|
|
482
497
|
|
|
498
|
+
// Provider-owned classification: never accept a caller/button's trust verdict.
|
|
499
|
+
// Recovery asks freshly; concurrent boot/recovery requests share one lookup.
|
|
500
|
+
const resolveRegisteredLane = useCallback(async (): Promise<'device' | 'oauth'> => {
|
|
501
|
+
const selected = registeredLaneRef.current;
|
|
502
|
+
if (selected.clientId !== clientIdProp || selected.services !== oxyServices) throw new Error('Provider classification was superseded');
|
|
503
|
+
if (selected.pending) return selected.pending;
|
|
504
|
+
selected.lane = null;
|
|
505
|
+
const pending = (async () => {
|
|
506
|
+
const lane = await resolveApplicationSessionLane(oxyServices, clientIdProp);
|
|
507
|
+
if (registeredLaneRef.current !== selected) throw new Error('Provider classification was superseded');
|
|
508
|
+
if (lane === 'oauth' && !hasIsolatedOAuthSession(runtime)) {
|
|
509
|
+
sessionClient.resetLocalState();
|
|
510
|
+
sessionClientHost.setDeviceCredential(null);
|
|
511
|
+
await clearSessionStateRef.current();
|
|
512
|
+
}
|
|
513
|
+
if (registeredLaneRef.current !== selected) throw new Error('Provider classification was superseded');
|
|
514
|
+
selected.lane = lane;
|
|
515
|
+
return lane;
|
|
516
|
+
})();
|
|
517
|
+
selected.pending = pending;
|
|
518
|
+
try { return await pending; } finally { if (selected.pending === pending) selected.pending = null; }
|
|
519
|
+
}, [oxyServices, clientIdProp, runtime, sessionClient, sessionClientHost]);
|
|
520
|
+
|
|
521
|
+
const ensureDeviceSessionLane = useCallback((): void | Promise<void> => {
|
|
522
|
+
if (registeredLaneRef.current.clientId !== clientIdProp || registeredLaneRef.current.services !== oxyServices) {
|
|
523
|
+
throw new Error('Provider classification was superseded');
|
|
524
|
+
}
|
|
525
|
+
if (registeredLaneRef.current.lane === 'oauth' || hasIsolatedOAuthSession(runtime)) {
|
|
526
|
+
throw new Error('This application requires explicit OAuth sign-in');
|
|
527
|
+
}
|
|
528
|
+
if (registeredLaneRef.current.lane === 'device') return;
|
|
529
|
+
return resolveRegisteredLane().then((lane) => {
|
|
530
|
+
if (registeredLaneRef.current.clientId !== clientIdProp || registeredLaneRef.current.services !== oxyServices) {
|
|
531
|
+
throw new Error('Provider classification was superseded');
|
|
532
|
+
}
|
|
533
|
+
if (lane !== 'device' || hasIsolatedOAuthSession(runtime)) {
|
|
534
|
+
throw new Error('This application requires explicit OAuth sign-in');
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}, [resolveRegisteredLane, runtime, clientIdProp, oxyServices]);
|
|
538
|
+
|
|
483
539
|
const { signIn, logout, logoutAll } = useAuthOperations({
|
|
540
|
+
ensureDeviceSessionLane,
|
|
484
541
|
oxyServices,
|
|
485
542
|
store: authStore,
|
|
486
543
|
storage,
|
|
@@ -564,8 +621,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
564
621
|
};
|
|
565
622
|
const recovery = createTokenLossRecovery({
|
|
566
623
|
remint: () => oxyServices.http.refreshAccessToken('preflight'),
|
|
567
|
-
hasDeviceCredential: () => hasPersistedSessionCredential(authStore),
|
|
624
|
+
hasDeviceCredential: () => registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime) ? Promise.resolve(false) : hasPersistedSessionCredential(authStore),
|
|
568
625
|
hasKeyedRecovery: async () => {
|
|
626
|
+
if (registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime)) return false;
|
|
569
627
|
if (Platform.OS === 'web') {
|
|
570
628
|
return false;
|
|
571
629
|
}
|
|
@@ -616,17 +674,23 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
616
674
|
// account instead of the device's active one, and its recovery arm becomes the
|
|
617
675
|
// PRIMARY-key identity sign-in rather than the cross-app shared keychain.
|
|
618
676
|
useEffect(() => {
|
|
619
|
-
|
|
677
|
+
let disposed = false;
|
|
678
|
+
const refresh = createAuthRefreshHandler({
|
|
620
679
|
oxy: oxyServices,
|
|
621
680
|
store: authStore,
|
|
681
|
+
isCurrent: () => !disposed && registeredLaneRef.current.lane === 'device' && !hasIsolatedOAuthSession(runtime),
|
|
622
682
|
...(identity ? { identity: identity.binding } : {}),
|
|
683
|
+
...(!identity && !isWebBrowser() ? {allowCommonsIdentityFallback: false, sharedDeviceCredential: createPlatformSharedDeviceCredentialStore() ?? undefined} : {}),
|
|
623
684
|
});
|
|
685
|
+
oxyServices.http.setAuthRefreshHandler((reason) =>
|
|
686
|
+
registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime) ? Promise.resolve(null) : refresh(reason));
|
|
624
687
|
const scheduler = startTokenRefreshScheduler(oxyServices);
|
|
625
688
|
return () => {
|
|
689
|
+
disposed = true;
|
|
626
690
|
scheduler.dispose();
|
|
627
|
-
|
|
691
|
+
oxyServices.http.setAuthRefreshHandler(null);
|
|
628
692
|
};
|
|
629
|
-
}, [oxyServices, authStore, identity]);
|
|
693
|
+
}, [oxyServices, authStore, identity, runtime]);
|
|
630
694
|
|
|
631
695
|
// ── Session commit funnel ────────────────────────────────────────────────
|
|
632
696
|
// The single place a session becomes committed: plant tokens, persist the
|
|
@@ -635,7 +699,20 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
635
699
|
// Used by the QR device flow (`handleWebSession`), password sign-in, 2FA
|
|
636
700
|
// completion, and the cold boot.
|
|
637
701
|
const commitSession = useCallback(
|
|
638
|
-
async (input: CommitInput, options: { activate: boolean }): Promise<void> => {
|
|
702
|
+
async (input: CommitInput, options: { activate: boolean; oauth?: boolean }): Promise<void> => {
|
|
703
|
+
const isolatedOAuth = options.oauth === true && !input.deviceId && !input.deviceSecret;
|
|
704
|
+
if (isolatedOAuth || hasIsolatedOAuthSession(runtime)) {
|
|
705
|
+
sessionClient.resetLocalState();
|
|
706
|
+
sessionClientHost.setDeviceCredential(null);
|
|
707
|
+
await authStore.clear();
|
|
708
|
+
await clearSessionStateRef.current();
|
|
709
|
+
}
|
|
710
|
+
if (isolatedOAuth) {
|
|
711
|
+
if (!clientId) throw new Error('An isolated OAuth commit requires its registered client ID');
|
|
712
|
+
setIsolatedOAuthSession(runtime, { sessionId: input.sessionId, clientId });
|
|
713
|
+
} else {
|
|
714
|
+
clearIsolatedOAuthSession(runtime);
|
|
715
|
+
}
|
|
639
716
|
if (input.accessToken) {
|
|
640
717
|
oxyServices.session.setAccessToken(input.accessToken);
|
|
641
718
|
}
|
|
@@ -684,7 +761,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
684
761
|
isCurrent: true,
|
|
685
762
|
},
|
|
686
763
|
],
|
|
687
|
-
{ merge:
|
|
764
|
+
{ merge: !isolatedOAuth },
|
|
688
765
|
);
|
|
689
766
|
runtime.setActiveSessionId(input.sessionId);
|
|
690
767
|
});
|
|
@@ -700,6 +777,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
700
777
|
// on the next load).
|
|
701
778
|
await commitDeviceSetAndResolve({
|
|
702
779
|
activate: options.activate,
|
|
780
|
+
isolatedOAuth,
|
|
703
781
|
hasDeviceState: input.deviceState !== undefined,
|
|
704
782
|
userId: input.userId,
|
|
705
783
|
fallbackUser: (input.user as unknown as User) ?? null,
|
|
@@ -712,8 +790,11 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
712
790
|
onAuthStateChange: onAuthStateChangeRef.current,
|
|
713
791
|
markAuthResolved: markAuthResolvedRef.current,
|
|
714
792
|
});
|
|
793
|
+
if (options.activate && !isolatedOAuth && !identity && input.userId && runtime.getSnapshot().account?.id === input.userId) {
|
|
794
|
+
if (await authStore.setAutomaticIdentitySignInSuppressed?.(false) === false) logger('Failed to release explicit sign-out intent after sign-in');
|
|
795
|
+
}
|
|
715
796
|
},
|
|
716
|
-
[oxyServices, authStore, runtime, sessionClient, sessionClientHost, syncFromClient, logger],
|
|
797
|
+
[oxyServices, authStore, runtime, sessionClient, sessionClientHost, syncFromClient, logger, clientId, identity],
|
|
717
798
|
);
|
|
718
799
|
const commitSessionRef = useRef(commitSession);
|
|
719
800
|
commitSessionRef.current = commitSession;
|
|
@@ -755,13 +836,22 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
755
836
|
clientId,
|
|
756
837
|
authorizeBaseUrl,
|
|
757
838
|
identityBound: isIdentityBound,
|
|
758
|
-
commitSession: (input) => commitSessionRef.current(input, { activate: true }),
|
|
839
|
+
commitSession: (input) => commitSessionRef.current(input, { activate: true, oauth: true }),
|
|
759
840
|
},
|
|
760
841
|
options,
|
|
761
842
|
),
|
|
762
843
|
[webAuthMode, oxyServices, clientId, authorizeBaseUrl, isIdentityBound],
|
|
763
844
|
);
|
|
764
845
|
|
|
846
|
+
const startNativeOAuthSignInForContext = useCallback(
|
|
847
|
+
(options: StartNativeOAuthSignInOptions) => startNativeOAuthSignIn({
|
|
848
|
+
platform: Platform.OS === 'ios' || Platform.OS === 'android' ? 'native' : 'unsupported',
|
|
849
|
+
oxyServices, clientId, authorizeBaseUrl, identityBound: isIdentityBound,
|
|
850
|
+
commitSession: (input) => commitSessionRef.current(input, { activate: true, oauth: true }),
|
|
851
|
+
}, options),
|
|
852
|
+
[oxyServices, clientId, authorizeBaseUrl, isIdentityBound],
|
|
853
|
+
);
|
|
854
|
+
|
|
765
855
|
const requestOAuthConsentForContext = useCallback(
|
|
766
856
|
(options: RequestOAuthConsentOptions): Promise<OAuthConsentResult> =>
|
|
767
857
|
requestOAuthConsent(
|
|
@@ -778,7 +868,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
778
868
|
authorizeBaseUrl,
|
|
779
869
|
identityBound: isIdentityBound,
|
|
780
870
|
expectedUserId: user?.id ?? null,
|
|
781
|
-
commitSession: (input) => commitSessionRef.current(input, { activate: true }),
|
|
871
|
+
commitSession: (input) => commitSessionRef.current(input, { activate: true, oauth: true }),
|
|
782
872
|
},
|
|
783
873
|
options,
|
|
784
874
|
),
|
|
@@ -869,8 +959,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
869
959
|
// Native is untouched: its apps already share a device through the keychain.
|
|
870
960
|
useEffect(() => {
|
|
871
961
|
if (!isWebBrowser()) return undefined;
|
|
872
|
-
return oxyServices.session.setDeviceCredentialProvider(() => loadPersistedDeviceCredential(authStore));
|
|
873
|
-
}, [oxyServices, authStore]);
|
|
962
|
+
return oxyServices.session.setDeviceCredentialProvider(() => registeredLaneRef.current.lane === 'device' && !hasIsolatedOAuthSession(runtime) ? loadPersistedDeviceCredential(authStore) : Promise.resolve(null));
|
|
963
|
+
}, [oxyServices, authStore, runtime]);
|
|
874
964
|
|
|
875
965
|
// ── The browser bridge (ADR 0029 D2) ──────────────────────────────────────
|
|
876
966
|
// The first time a person presses sign-in in this app on the web and it holds
|
|
@@ -883,7 +973,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
883
973
|
const bridgeInFlightRef = useRef(false);
|
|
884
974
|
const closeAccountDialogRef = useRef<() => void>(() => undefined);
|
|
885
975
|
const startBrowserBridge = useCallback((): void => {
|
|
886
|
-
if (isIdentityBound || !clientId || !isWebBrowser() || bridgeInFlightRef.current) return;
|
|
976
|
+
if (registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime) || isIdentityBound || !clientId || !isWebBrowser() || bridgeInFlightRef.current) return;
|
|
887
977
|
if (authStoreRef.current?.heldDeviceCredential() !== null) return;
|
|
888
978
|
const bridgeOrigin = resolveBridgeOrigin(authorizeBaseUrl);
|
|
889
979
|
const pageOrigin = globalThis.location?.origin;
|
|
@@ -940,9 +1030,12 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
940
1030
|
authStore,
|
|
941
1031
|
sessionClientHost,
|
|
942
1032
|
logger,
|
|
1033
|
+
runtime,
|
|
943
1034
|
]);
|
|
944
1035
|
|
|
945
|
-
const
|
|
1036
|
+
const openClassifiedAccountDialog = useCallback((view?: AccountDialogView): void => {
|
|
1037
|
+
if (registeredLaneRef.current.clientId !== clientIdProp || registeredLaneRef.current.services !== oxyServices) return;
|
|
1038
|
+
if (registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime)) return;
|
|
946
1039
|
if (isIdentityBound) {
|
|
947
1040
|
// No controller was built, and there is nothing to choose: this app's user
|
|
948
1041
|
// is fixed to the owner of the local identity key.
|
|
@@ -999,7 +1092,20 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
999
1092
|
}
|
|
1000
1093
|
});
|
|
1001
1094
|
setAccountDialogOpen(true);
|
|
1002
|
-
}, [isIdentityBound, startBrowserBridge]);
|
|
1095
|
+
}, [isIdentityBound, startBrowserBridge, runtime, clientIdProp, oxyServices]);
|
|
1096
|
+
|
|
1097
|
+
const openAccountDialog = useCallback((view?: AccountDialogView): void => {
|
|
1098
|
+
if (registeredLaneRef.current.clientId !== clientIdProp || registeredLaneRef.current.services !== oxyServices) return;
|
|
1099
|
+
if (registeredLaneRef.current.lane === 'device') {
|
|
1100
|
+
// Preserve the browser bridge's synchronous user-gesture path.
|
|
1101
|
+
openClassifiedAccountDialog(view);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
void Promise.resolve().then(() => ensureDeviceSessionLane()).then(() => openClassifiedAccountDialog(view)).catch((error) => {
|
|
1105
|
+
handleAuthError(error, { defaultMessage: 'Sign in failed', code: 'LOGIN_ERROR', onError,
|
|
1106
|
+
setAuthError: (message) => runtime.setError(message), logger });
|
|
1107
|
+
});
|
|
1108
|
+
}, [ensureDeviceSessionLane, openClassifiedAccountDialog, onError, runtime, logger, clientIdProp, oxyServices]);
|
|
1003
1109
|
|
|
1004
1110
|
const closeAccountDialog = useCallback((): void => {
|
|
1005
1111
|
accountDialogControllerRef.current?.cancelSignIn();
|
|
@@ -1043,9 +1149,6 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1043
1149
|
// SYNCHRONOUS read on `SessionClient`'s apply path, and a stale `null` there
|
|
1044
1150
|
// would let a pinned client behave like an account-mode one for the first
|
|
1045
1151
|
// state it applies (including re-electing the device's active account).
|
|
1046
|
-
if (identity) {
|
|
1047
|
-
await identity.ensurePinnedAccountId();
|
|
1048
|
-
}
|
|
1049
1152
|
await runProviderColdBoot({
|
|
1050
1153
|
oxyServices,
|
|
1051
1154
|
authStore,
|
|
@@ -1053,6 +1156,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1053
1156
|
authRedirectUri,
|
|
1054
1157
|
sessionMode,
|
|
1055
1158
|
identity: identity?.binding,
|
|
1159
|
+
resolveSessionLane: resolveRegisteredLane,
|
|
1160
|
+
beforeDeviceBoot: identity ? async () => { await identity.ensurePinnedAccountId(); } : undefined,
|
|
1161
|
+
afterDeviceBoot: identity ? async () => { await identity.refreshPinnedAccountId(); } : undefined,
|
|
1056
1162
|
sessionClient,
|
|
1057
1163
|
syncDeviceCredentialToHost,
|
|
1058
1164
|
commitSession: (input, options) => commitSessionRef.current(input, options),
|
|
@@ -1062,10 +1168,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1062
1168
|
// The boot may have (re-)established the identity session or cleared a pin
|
|
1063
1169
|
// whose key no longer exists, so re-resolve rather than trusting the memo
|
|
1064
1170
|
// taken before it ran.
|
|
1065
|
-
if (identity) {
|
|
1066
|
-
await identity.refreshPinnedAccountId();
|
|
1067
|
-
}
|
|
1068
1171
|
}, [
|
|
1172
|
+
resolveRegisteredLane,
|
|
1069
1173
|
oxyServices,
|
|
1070
1174
|
authStore,
|
|
1071
1175
|
clientIdProp,
|
|
@@ -1092,30 +1196,49 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1092
1196
|
});
|
|
1093
1197
|
}, [runColdBoot, storage, initialized, logger]);
|
|
1094
1198
|
|
|
1095
|
-
// Reconcile device state
|
|
1096
|
-
// miss
|
|
1199
|
+
// Reconcile device state on foreground: suspended native apps and background
|
|
1200
|
+
// tabs may miss a sibling's active-context switch or socket pushes.
|
|
1097
1201
|
useEffect(() => {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
//
|
|
1105
|
-
// double-rotate the device secret against them.
|
|
1202
|
+
let disposed = false;
|
|
1203
|
+
let pending: Promise<void> | null = null;
|
|
1204
|
+
const reconcile = (): void => {
|
|
1205
|
+
if (disposed || pending || hasIsolatedOAuthSession(runtime)) return;
|
|
1206
|
+
pending = (async (): Promise<void> => {
|
|
1207
|
+
if ((!isWebBrowser() && !identity) || (!oxyServices.session.accessToken && sessionClientHost.getDeviceCredential())) {
|
|
1208
|
+
// The scheduler/preflight/401 paths own the same mint single-flight.
|
|
1106
1209
|
await oxyServices.http.refreshAccessToken('preflight');
|
|
1107
1210
|
}
|
|
1211
|
+
if (disposed || hasIsolatedOAuthSession(runtime)) return;
|
|
1212
|
+
if (!isWebBrowser() && !identity) await syncDeviceCredentialToHost();
|
|
1108
1213
|
if (!oxyServices.session.accessToken && !sessionClientHost.getDeviceCredential()) {
|
|
1109
1214
|
return;
|
|
1110
1215
|
}
|
|
1111
1216
|
await sessionClient.bootstrap();
|
|
1217
|
+
if (disposed || hasIsolatedOAuthSession(runtime)) return;
|
|
1112
1218
|
await syncFromClient();
|
|
1219
|
+
})().catch(() => undefined).finally(() => { pending = null; });
|
|
1220
|
+
};
|
|
1221
|
+
if (isWebBrowser()) {
|
|
1222
|
+
const onVisibility = (): void => {
|
|
1223
|
+
if (document.visibilityState === 'visible') reconcile();
|
|
1113
1224
|
};
|
|
1114
|
-
|
|
1225
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
1226
|
+
return () => {
|
|
1227
|
+
disposed = true;
|
|
1228
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
let previousState = AppState.currentState;
|
|
1232
|
+
const subscription = AppState.addEventListener('change', (state) => {
|
|
1233
|
+
const resumed = state === 'active' && (previousState === 'inactive' || previousState === 'background');
|
|
1234
|
+
previousState = state;
|
|
1235
|
+
if (resumed) reconcile();
|
|
1236
|
+
});
|
|
1237
|
+
return () => {
|
|
1238
|
+
disposed = true;
|
|
1239
|
+
subscription.remove();
|
|
1115
1240
|
};
|
|
1116
|
-
|
|
1117
|
-
return () => document.removeEventListener('visibilitychange', onVisibility);
|
|
1118
|
-
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
|
|
1241
|
+
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient, syncDeviceCredentialToHost, identity, runtime]);
|
|
1119
1242
|
|
|
1120
1243
|
// Reconnect heal: when connectivity transitions offline→online while there is
|
|
1121
1244
|
// no live access token but a persisted device credential exists, re-mint ONCE
|
|
@@ -1139,6 +1262,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1139
1262
|
return;
|
|
1140
1263
|
}
|
|
1141
1264
|
void (async () => {
|
|
1265
|
+
if (hasIsolatedOAuthSession(runtime)) return;
|
|
1142
1266
|
if (oxyServices.session.accessToken || !sessionClientHost.getDeviceCredential()) {
|
|
1143
1267
|
return;
|
|
1144
1268
|
}
|
|
@@ -1150,14 +1274,15 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1150
1274
|
await syncFromClient();
|
|
1151
1275
|
})().catch(() => undefined);
|
|
1152
1276
|
});
|
|
1153
|
-
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
|
|
1277
|
+
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient, runtime]);
|
|
1154
1278
|
|
|
1155
1279
|
// Exposed `refreshSessions`: re-bootstrap the server-authoritative device
|
|
1156
1280
|
// state and reproject — the manual counterpart to the realtime socket.
|
|
1157
1281
|
const refreshSessionsForContext = useCallback(async (): Promise<void> => {
|
|
1282
|
+
if (hasIsolatedOAuthSession(runtime)) throw new Error('An isolated OAuth session has no shared device sessions');
|
|
1158
1283
|
await sessionClient.bootstrap();
|
|
1159
1284
|
await syncFromClient();
|
|
1160
|
-
}, [sessionClient, syncFromClient]);
|
|
1285
|
+
}, [sessionClient, syncFromClient, runtime]);
|
|
1161
1286
|
|
|
1162
1287
|
// Exposed `switchSession`: route through the server-authoritative
|
|
1163
1288
|
// `SessionClient`. Resolve the target account from the current device state,
|
|
@@ -1167,6 +1292,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1167
1292
|
// `switchToAccount`, so leaving it live would be a bypass around the same rule.
|
|
1168
1293
|
const switchSessionForContext = useCallback(
|
|
1169
1294
|
async (sessionId: string): Promise<User> => {
|
|
1295
|
+
if (hasIsolatedOAuthSession(runtime)) throw new Error('An isolated OAuth session cannot switch device sessions');
|
|
1170
1296
|
if (isIdentityBound) {
|
|
1171
1297
|
throw new IdentityBoundSessionError('switchSession');
|
|
1172
1298
|
}
|
|
@@ -1216,6 +1342,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1216
1342
|
tokenReady,
|
|
1217
1343
|
initialized,
|
|
1218
1344
|
identityBound: isIdentityBound,
|
|
1345
|
+
isolatedOAuth: readIsolatedOAuthSession(runtime) !== null,
|
|
1219
1346
|
oxyServices,
|
|
1220
1347
|
sessionClient,
|
|
1221
1348
|
syncFromClient,
|
|
@@ -1303,6 +1430,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1303
1430
|
revokeSuspiciousSignIn,
|
|
1304
1431
|
handleWebSession,
|
|
1305
1432
|
startWebOAuthSignIn: startWebOAuthSignInForContext,
|
|
1433
|
+
startNativeOAuthSignIn: startNativeOAuthSignInForContext,
|
|
1306
1434
|
requestOAuthConsent: requestOAuthConsentForContext,
|
|
1307
1435
|
logout,
|
|
1308
1436
|
logoutAll,
|
|
@@ -1358,6 +1486,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1358
1486
|
revokeSuspiciousSignIn,
|
|
1359
1487
|
handleWebSession,
|
|
1360
1488
|
startWebOAuthSignInForContext,
|
|
1489
|
+
startNativeOAuthSignInForContext,
|
|
1361
1490
|
requestOAuthConsentForContext,
|
|
1362
1491
|
logout,
|
|
1363
1492
|
logoutAll,
|
|
@@ -1388,6 +1517,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1388
1517
|
|
|
1389
1518
|
const authActionsValue = useMemo<OxyAuthActionsContextState>(
|
|
1390
1519
|
() => ({
|
|
1520
|
+
ensureDeviceSessionLane,
|
|
1391
1521
|
signIn,
|
|
1392
1522
|
logout,
|
|
1393
1523
|
logoutAll,
|
|
@@ -1401,6 +1531,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1401
1531
|
openAccountDialog,
|
|
1402
1532
|
}),
|
|
1403
1533
|
[
|
|
1534
|
+
ensureDeviceSessionLane,
|
|
1404
1535
|
signIn,
|
|
1405
1536
|
logout,
|
|
1406
1537
|
logoutAll,
|
|
@@ -242,3 +242,16 @@ describe('commitDeviceSetAndResolve — deliberate sign-in (activate: true)', ()
|
|
|
242
242
|
expect(settled).toHaveBeenCalledTimes(1);
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
describe('isolated OAuth session commit', () => {
|
|
247
|
+
it('hydrates and resolves without device registration, projection or socket', async () => {
|
|
248
|
+
const order: string[] = [];
|
|
249
|
+
const deps = buildDeps(order, { activate: true, isolatedOAuth: true });
|
|
250
|
+
await commitDeviceSetAndResolve(deps);
|
|
251
|
+
expect(order).toEqual(['getCurrentUser', 'loginSuccess', 'onAuthStateChange', 'markAuthResolved']);
|
|
252
|
+
expect(deps.registerAndActivate).not.toHaveBeenCalled();
|
|
253
|
+
expect(deps.addCurrentAccount).not.toHaveBeenCalled();
|
|
254
|
+
expect(deps.startSocket).not.toHaveBeenCalled();
|
|
255
|
+
expect(deps.syncFromClient).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { logoutIsolatedOAuthSession } from '../isolatedOAuthSession';
|
|
2
|
+
|
|
3
|
+
const session = { sessionId: 'isolated-session', clientId: 'oxy_dk_external' };
|
|
4
|
+
function deps() {
|
|
5
|
+
return { session, revokeSelf: jest.fn().mockResolvedValue(undefined), clearSessionState: jest.fn().mockResolvedValue(undefined) };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
it('revokes exactly itself before clearing runtime/cache/token state', async () => {
|
|
9
|
+
const input = deps();
|
|
10
|
+
await expect(logoutIsolatedOAuthSession(input)).resolves.toEqual({ status: 'signed-out' });
|
|
11
|
+
expect(input.revokeSelf).toHaveBeenCalledWith(session.sessionId);
|
|
12
|
+
expect(input.clearSessionState).toHaveBeenCalledTimes(1);
|
|
13
|
+
expect(input.revokeSelf.mock.invocationCallOrder[0]).toBeLessThan(input.clearSessionState.mock.invocationCallOrder[0]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects another target before contacting any revocation endpoint', async () => {
|
|
17
|
+
const input = deps();
|
|
18
|
+
expect((await logoutIsolatedOAuthSession({ ...input, targetSessionId: 'other-app-session' })).status).toBe('failed');
|
|
19
|
+
expect(input.revokeSelf).not.toHaveBeenCalled();
|
|
20
|
+
expect(input.clearSessionState).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('clears an already-invalid session on 401', async () => {
|
|
24
|
+
const input = deps();
|
|
25
|
+
input.revokeSelf.mockRejectedValue({ status: 401, code: 'INVALID_SESSION' });
|
|
26
|
+
expect((await logoutIsolatedOAuthSession(input)).status).toBe('signed-out');
|
|
27
|
+
expect(input.clearSessionState).toHaveBeenCalledTimes(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('preserves the session when the server could not complete revocation', async () => {
|
|
31
|
+
const input = deps();
|
|
32
|
+
input.revokeSelf.mockRejectedValue({ status: 503 });
|
|
33
|
+
expect((await logoutIsolatedOAuthSession(input)).status).toBe('failed');
|
|
34
|
+
expect(input.clearSessionState).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
@@ -12,6 +12,8 @@ export interface CommitDeviceSetAndResolveDeps {
|
|
|
12
12
|
* reconciles the device set in the background).
|
|
13
13
|
*/
|
|
14
14
|
activate: boolean;
|
|
15
|
+
/** Trusted provider provenance from the completed third-party OAuth exchange. */
|
|
16
|
+
isolatedOAuth?: boolean;
|
|
15
17
|
/** Cold boot already carries authoritative device state from the mint. */
|
|
16
18
|
hasDeviceState?: boolean;
|
|
17
19
|
/** The committing account id (only used by `registerAndActivate`). */
|
|
@@ -48,6 +50,7 @@ export async function commitDeviceSetAndResolve(
|
|
|
48
50
|
): Promise<void> {
|
|
49
51
|
const {
|
|
50
52
|
activate,
|
|
53
|
+
isolatedOAuth = false,
|
|
51
54
|
hasDeviceState = false,
|
|
52
55
|
userId,
|
|
53
56
|
fallbackUser,
|
|
@@ -111,6 +114,11 @@ export async function commitDeviceSetAndResolve(
|
|
|
111
114
|
markAuthResolved();
|
|
112
115
|
};
|
|
113
116
|
|
|
117
|
+
if (isolatedOAuth) {
|
|
118
|
+
await hydrateAndResolve();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
114
122
|
if (activate) {
|
|
115
123
|
// Deliberate sign-in: block on the device-set reconcile (unchanged ordering)
|
|
116
124
|
// before hydrating + resolving, so the account is fully registered/active.
|