@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.
Files changed (145) hide show
  1. package/lib/commonjs/index.js +13 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/boot/runProviderColdBoot.js +14 -1
  4. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxySignInButton.js +25 -7
  6. package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
  7. package/lib/commonjs/ui/context/OxyContext.js +198 -41
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/commitSessionFlow.js +5 -0
  10. package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -1
  11. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +56 -9
  12. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  13. package/lib/commonjs/ui/context/isolatedOAuthSession.js +52 -0
  14. package/lib/commonjs/ui/context/isolatedOAuthSession.js.map +1 -0
  15. package/lib/commonjs/ui/context/useOxyAccountGraph.js +7 -5
  16. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/queries/usePersonalPlans.js +43 -0
  18. package/lib/commonjs/ui/hooks/queries/usePersonalPlans.js.map +1 -0
  19. package/lib/commonjs/ui/hooks/useAuth.js +7 -3
  20. package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
  21. package/lib/commonjs/ui/oauth/applicationSessionLane.js +32 -0
  22. package/lib/commonjs/ui/oauth/applicationSessionLane.js.map +1 -0
  23. package/lib/commonjs/ui/oauth/completeOAuthCode.js +4 -2
  24. package/lib/commonjs/ui/oauth/completeOAuthCode.js.map +1 -1
  25. package/lib/commonjs/ui/oauth/nativeAuthTransport.js +119 -0
  26. package/lib/commonjs/ui/oauth/nativeAuthTransport.js.map +1 -0
  27. package/lib/commonjs/ui/session/createSessionClient.js +14 -2
  28. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
  29. package/lib/commonjs/ui/session/deviceCredentialTracker.js +8 -0
  30. package/lib/commonjs/ui/session/deviceCredentialTracker.js.map +1 -1
  31. package/lib/module/index.js +1 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/ui/boot/runProviderColdBoot.js +14 -1
  34. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  35. package/lib/module/ui/components/OxySignInButton.js +25 -7
  36. package/lib/module/ui/components/OxySignInButton.js.map +1 -1
  37. package/lib/module/ui/context/OxyContext.js +200 -43
  38. package/lib/module/ui/context/OxyContext.js.map +1 -1
  39. package/lib/module/ui/context/commitSessionFlow.js +5 -0
  40. package/lib/module/ui/context/commitSessionFlow.js.map +1 -1
  41. package/lib/module/ui/context/hooks/useAuthOperations.js +56 -9
  42. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  43. package/lib/module/ui/context/isolatedOAuthSession.js +44 -0
  44. package/lib/module/ui/context/isolatedOAuthSession.js.map +1 -0
  45. package/lib/module/ui/context/useOxyAccountGraph.js +7 -5
  46. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  47. package/lib/module/ui/hooks/queries/usePersonalPlans.js +39 -0
  48. package/lib/module/ui/hooks/queries/usePersonalPlans.js.map +1 -0
  49. package/lib/module/ui/hooks/useAuth.js +7 -3
  50. package/lib/module/ui/hooks/useAuth.js.map +1 -1
  51. package/lib/module/ui/oauth/applicationSessionLane.js +26 -0
  52. package/lib/module/ui/oauth/applicationSessionLane.js.map +1 -0
  53. package/lib/module/ui/oauth/completeOAuthCode.js +4 -2
  54. package/lib/module/ui/oauth/completeOAuthCode.js.map +1 -1
  55. package/lib/module/ui/oauth/nativeAuthTransport.js +115 -0
  56. package/lib/module/ui/oauth/nativeAuthTransport.js.map +1 -0
  57. package/lib/module/ui/session/createSessionClient.js +14 -2
  58. package/lib/module/ui/session/createSessionClient.js.map +1 -1
  59. package/lib/module/ui/session/deviceCredentialTracker.js +8 -0
  60. package/lib/module/ui/session/deviceCredentialTracker.js.map +1 -1
  61. package/lib/typescript/commonjs/index.d.ts +2 -0
  62. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +7 -0
  64. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +2 -0
  66. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +3 -1
  68. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts +2 -0
  70. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +3 -2
  72. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/ui/context/isolatedOAuthSession.d.ts +19 -0
  74. package/lib/typescript/commonjs/ui/context/isolatedOAuthSession.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +3 -0
  76. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts +2 -1
  78. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +6 -0
  80. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +64 -0
  82. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/ui/oauth/applicationSessionLane.d.ts +6 -0
  85. package/lib/typescript/commonjs/ui/oauth/applicationSessionLane.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/ui/oauth/completeOAuthCode.d.ts +2 -2
  87. package/lib/typescript/commonjs/ui/oauth/nativeAuthTransport.d.ts +28 -0
  88. package/lib/typescript/commonjs/ui/oauth/nativeAuthTransport.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +1 -1
  90. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/session/deviceCredentialTracker.d.ts.map +1 -1
  92. package/lib/typescript/module/index.d.ts +2 -0
  93. package/lib/typescript/module/index.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +7 -0
  95. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/components/OxySignInButton.d.ts +2 -0
  97. package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/context/OxyContext.d.ts +3 -1
  99. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  100. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts +2 -0
  101. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -1
  102. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +3 -2
  103. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  104. package/lib/typescript/module/ui/context/isolatedOAuthSession.d.ts +19 -0
  105. package/lib/typescript/module/ui/context/isolatedOAuthSession.d.ts.map +1 -0
  106. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +3 -0
  107. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  108. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts +2 -1
  109. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  110. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +6 -0
  111. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  112. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +64 -0
  113. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -0
  114. package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/oauth/applicationSessionLane.d.ts +6 -0
  116. package/lib/typescript/module/ui/oauth/applicationSessionLane.d.ts.map +1 -0
  117. package/lib/typescript/module/ui/oauth/completeOAuthCode.d.ts +2 -2
  118. package/lib/typescript/module/ui/oauth/nativeAuthTransport.d.ts +28 -0
  119. package/lib/typescript/module/ui/oauth/nativeAuthTransport.d.ts.map +1 -0
  120. package/lib/typescript/module/ui/session/createSessionClient.d.ts +1 -1
  121. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/session/deviceCredentialTracker.d.ts.map +1 -1
  123. package/package.json +30 -30
  124. package/src/index.ts +4 -0
  125. package/src/ui/boot/__tests__/coldBootNoIdpRedirect.test.ts +56 -2
  126. package/src/ui/boot/runProviderColdBoot.ts +22 -2
  127. package/src/ui/components/OxySignInButton.tsx +20 -4
  128. package/src/ui/context/OxyContext.tsx +171 -40
  129. package/src/ui/context/__tests__/commitSessionFlow.test.ts +13 -0
  130. package/src/ui/context/__tests__/isolatedOAuthSession.test.ts +35 -0
  131. package/src/ui/context/commitSessionFlow.ts +8 -0
  132. package/src/ui/context/hooks/useAuthOperations.ts +55 -9
  133. package/src/ui/context/isolatedOAuthSession.ts +48 -0
  134. package/src/ui/context/oxyContextTypes.ts +4 -0
  135. package/src/ui/context/useOxyAccountGraph.ts +8 -5
  136. package/src/ui/hooks/queries/usePersonalPlans.ts +23 -0
  137. package/src/ui/hooks/useAuth.ts +6 -2
  138. package/src/ui/oauth/__tests__/completeOAuthCode.test.ts +1 -1
  139. package/src/ui/oauth/__tests__/nativeAuthTransport.test.ts +57 -0
  140. package/src/ui/oauth/__tests__/oauthBearerCommit.http.test.ts +131 -0
  141. package/src/ui/oauth/applicationSessionLane.ts +26 -0
  142. package/src/ui/oauth/completeOAuthCode.ts +3 -3
  143. package/src/ui/oauth/nativeAuthTransport.ts +67 -0
  144. package/src/ui/session/createSessionClient.ts +13 -1
  145. 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, installAuthRefreshHandler, startTokenRefreshScheduler, createAccountDialogController } from '@oxy.so/core/session';
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
- const dispose = installAuthRefreshHandler({
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
- dispose();
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: true },
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 openAccountDialog = useCallback((view?: AccountDialogView): void => {
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 when the tab returns to foreground (background tabs may
1096
- // miss socket pushes or have stale bearer tokens).
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
- if (!isWebBrowser()) return;
1099
- const onVisibility = (): void => {
1100
- if (document.visibilityState !== 'visible') return;
1101
- const reconcile = async (): Promise<void> => {
1102
- if (!oxyServices.session.accessToken && sessionClientHost.getDeviceCredential()) {
1103
- // Route through the ONE shared single-flight the scheduler/preflight/401
1104
- // use — never a private mint lane — so a tab-focus reconcile can't
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
- void reconcile().catch(() => undefined);
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
- document.addEventListener('visibilitychange', onVisibility);
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.