@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,4 +1,5 @@
1
1
  import { useCallback } from 'react';
2
+ import { logoutIsolatedOAuthSession, readIsolatedOAuthSession } from '../isolatedOAuthSession';
2
3
  import type { ApiError, User } from '@oxy.so/core';
3
4
  import type { AuthStateStore, IdentityBinding, SessionClient } from '@oxy.so/core/session';
4
5
  import type { ClientSession, SessionLoginResponse } from '@oxy.so/core';
@@ -13,6 +14,7 @@ import { SignatureService } from '@oxy.so/core/crypto';
13
14
 
14
15
  export interface UseAuthOperationsOptions {
15
16
  oxyServices: OxyServices;
17
+ ensureDeviceSessionLane: () => void | Promise<void>;
16
18
  storage: StorageInterface | null;
17
19
  /**
18
20
  * The device-first persisted auth-state store. On EXPLICIT full sign-out the
@@ -79,8 +81,8 @@ const LOGOUT_ALL_ERROR_CODE = 'LOGOUT_ALL_ERROR';
79
81
  export function clearPersistedAuthSafe(
80
82
  store: AuthStateStore,
81
83
  logger?: (message: string, error?: unknown) => void,
82
- ): void {
83
- store.clear().catch((clearError) => {
84
+ ): Promise<void> {
85
+ return store.clear().catch((clearError) => {
84
86
  logger?.('Failed to clear persisted auth state on sign-out', clearError);
85
87
  });
86
88
  }
@@ -91,6 +93,7 @@ export function clearPersistedAuthSafe(
91
93
  */
92
94
  export const useAuthOperations = ({
93
95
  oxyServices,
96
+ ensureDeviceSessionLane,
94
97
  store,
95
98
  runtime,
96
99
  saveActiveSessionId,
@@ -109,6 +112,7 @@ export const useAuthOperations = ({
109
112
  */
110
113
  const performSignIn = useCallback(
111
114
  async (publicKey: string): Promise<User> => {
115
+ await ensureDeviceSessionLane();
112
116
  const deviceFingerprintObj = DeviceManager.getDeviceFingerprint();
113
117
  const deviceFingerprint = JSON.stringify(deviceFingerprintObj);
114
118
  const deviceInfo = await DeviceManager.getDeviceInfo();
@@ -181,9 +185,11 @@ export const useAuthOperations = ({
181
185
  // exposed sessions/activeSessionId/user. Best-effort: a failure here must
182
186
  // NEVER fail the sign-in itself — cold boot re-registers this account into
183
187
  // the device set on the next load regardless.
188
+ let deviceRegistrationCommitted = false;
184
189
  try {
185
190
  await sessionClient.registerAndActivate(sessionResponse.user.id);
186
191
  await syncFromClient();
192
+ deviceRegistrationCommitted = true;
187
193
  } catch (registrationError) {
188
194
  logger?.('Failed to register sign-in into device session set', registrationError);
189
195
  }
@@ -231,6 +237,7 @@ export const useAuthOperations = ({
231
237
  { merge: false },
232
238
  );
233
239
  onAuthStateChange?.(fullUser);
240
+ if (deviceRegistrationCommitted && !identityBinding && await store.setAutomaticIdentitySignInSuppressed?.(false) === false) logger?.('Failed to release explicit sign-out intent after sign-in');
234
241
  return fullUser;
235
242
  }
236
243
 
@@ -243,10 +250,11 @@ export const useAuthOperations = ({
243
250
  });
244
251
  await saveActiveSessionId(sessionResponse.sessionId);
245
252
  onAuthStateChange?.(fullUser);
246
-
253
+ if (deviceRegistrationCommitted && !identityBinding && await store.setAutomaticIdentitySignInSuppressed?.(false) === false) logger?.('Failed to release explicit sign-out intent after sign-in');
247
254
  return fullUser;
248
255
  },
249
256
  [
257
+ ensureDeviceSessionLane,
250
258
  logger,
251
259
  onAuthStateChange,
252
260
  oxyServices,
@@ -298,6 +306,23 @@ export const useAuthOperations = ({
298
306
  const activeSessionId = runtime.getSnapshot().activeSessionId;
299
307
  if (!activeSessionId) return { status: 'signed-out' };
300
308
 
309
+ const isolated = readIsolatedOAuthSession(runtime);
310
+ if (isolated) {
311
+ const result = await logoutIsolatedOAuthSession({
312
+ session: isolated,
313
+ targetSessionId,
314
+ revokeSelf: (sessionId) => oxyServices.session.logout(sessionId),
315
+ clearSessionState,
316
+ });
317
+ if (result.status === 'failed') {
318
+ handleAuthError(result.error, {
319
+ defaultMessage: 'Logout failed', code: LOGOUT_ERROR_CODE, onError,
320
+ setAuthError: (message) => runtime.setError(message), logger,
321
+ });
322
+ }
323
+ return result;
324
+ }
325
+
301
326
  const sessionToLogout = targetSessionId || activeSessionId;
302
327
 
303
328
  try {
@@ -325,7 +350,8 @@ export const useAuthOperations = ({
325
350
  // Genuine FULL sign-out (no sessions remain): clear the persisted
326
351
  // device credential so a reload's cold boot finds nothing to restore,
327
352
  // then tear down local state.
328
- clearPersistedAuthSafe(store, logger);
353
+ if (!identityBinding && await store.setAutomaticIdentitySignInSuppressed?.(true) === false) throw new Error('Failed to persist explicit sign-out intent');
354
+ await clearPersistedAuthSafe(store, logger);
329
355
  await clearSessionState();
330
356
  }
331
357
  return { status: 'signed-out' };
@@ -338,11 +364,16 @@ export const useAuthOperations = ({
338
364
  // "signed in" against a bearer the 401 lane has already cleared.
339
365
  if (isInvalid && sessionToLogout === activeSessionId) {
340
366
  // The active session is invalid → full sign-out; clear persisted state.
341
- clearPersistedAuthSafe(store, logger);
367
+ const intentPersisted = identityBinding ? true : await store.setAutomaticIdentitySignInSuppressed?.(true);
368
+ await clearPersistedAuthSafe(store, logger);
342
369
  await clearSessionState();
343
- return { status: 'signed-out' };
370
+ return intentPersisted === false ? { status: 'failed', error: new Error('Failed to persist explicit sign-out intent') } : { status: 'signed-out' };
344
371
  }
345
372
 
373
+ if (sessionClient.getState()?.accounts.length === 0) {
374
+ await clearPersistedAuthSafe(store, logger);
375
+ await clearSessionState();
376
+ }
346
377
  handleAuthError(error, {
347
378
  defaultMessage: 'Logout failed',
348
379
  code: LOGOUT_ERROR_CODE,
@@ -358,12 +389,14 @@ export const useAuthOperations = ({
358
389
  },
359
390
  [
360
391
  clearSessionState,
392
+ oxyServices,
361
393
  store,
362
394
  logger,
363
395
  onError,
364
396
  runtime,
365
397
  sessionClient,
366
398
  syncFromClient,
399
+ identityBinding,
367
400
  ],
368
401
  );
369
402
 
@@ -371,6 +404,9 @@ export const useAuthOperations = ({
371
404
  * Logout from all sessions
372
405
  */
373
406
  const logoutAll = useCallback(async (): Promise<void> => {
407
+ if (readIsolatedOAuthSession(runtime)) {
408
+ throw new Error('An isolated OAuth session cannot sign out other sessions');
409
+ }
374
410
  const activeSessionId = runtime.getSnapshot().activeSessionId;
375
411
  if (!activeSessionId) {
376
412
  const error = new Error('No active session found');
@@ -379,6 +415,7 @@ export const useAuthOperations = ({
379
415
  throw error;
380
416
  }
381
417
 
418
+ let deviceSignOutAttempted = false;
382
419
  try {
383
420
  // Revoke the user's sessions on every other device and every refresh-
384
421
  // token family first. SessionClient's `{ all: true }` operation is only
@@ -386,11 +423,13 @@ export const useAuthOperations = ({
386
423
  // itself. The global endpoint deliberately preserves the current
387
424
  // session long enough for the device-scoped cleanup below to authenticate.
388
425
  await oxyServices.session.logoutAll(activeSessionId);
426
+ deviceSignOutAttempted = true;
389
427
  await sessionClient.signOut({ all: true });
390
428
  // logoutAll is ALWAYS a full sign-out: clear the persisted device
391
429
  // credential so the next cold boot finds no session to restore, then tear
392
430
  // down local state.
393
- clearPersistedAuthSafe(store, logger);
431
+ if (!identityBinding && await store.setAutomaticIdentitySignInSuppressed?.(true) === false) throw new Error('Failed to persist explicit sign-out intent');
432
+ await clearPersistedAuthSafe(store, logger);
394
433
  await clearSessionState();
395
434
  } catch (error) {
396
435
  if (isInvalidSessionError(error)) {
@@ -403,11 +442,18 @@ export const useAuthOperations = ({
403
442
  // `onTokensChanged(null)` by now, so rejecting here would contradict
404
443
  // the SDK's own authoritative 401 lane and hand the caller a failure
405
444
  // for work that is complete. Finish the local teardown and resolve.
406
- clearPersistedAuthSafe(store, logger);
445
+ const intentPersisted = identityBinding ? true : await store.setAutomaticIdentitySignInSuppressed?.(true);
446
+ await clearPersistedAuthSafe(store, logger);
407
447
  await clearSessionState();
448
+ if (intentPersisted === false) throw new Error('Failed to persist explicit sign-out intent');
408
449
  return;
409
450
  }
410
451
 
452
+ if (deviceSignOutAttempted && sessionClient.getState()?.accounts.length === 0) {
453
+ await clearPersistedAuthSafe(store, logger);
454
+ await clearSessionState();
455
+ }
456
+
411
457
  const message = handleAuthError(error, {
412
458
  defaultMessage: 'Logout all failed',
413
459
  code: LOGOUT_ALL_ERROR_CODE,
@@ -421,7 +467,7 @@ export const useAuthOperations = ({
421
467
  // would erase the server's reason from every caller's toast.
422
468
  throw error instanceof Error ? error : new Error(message);
423
469
  }
424
- }, [clearSessionState, store, logger, onError, oxyServices, runtime, sessionClient]);
470
+ }, [clearSessionState, store, logger, onError, oxyServices, runtime, sessionClient, identityBinding]);
425
471
 
426
472
  return {
427
473
  signIn,
@@ -0,0 +1,48 @@
1
+ import type { OxyRuntime } from '../runtime';
2
+ import type { LogoutResult } from './oxyContextTypes';
3
+ import { isInvalidSessionError } from '../utils/errorHandlers';
4
+
5
+ interface IsolatedOAuthSession {
6
+ readonly sessionId: string;
7
+ readonly clientId: string;
8
+ }
9
+
10
+ // Provider-local, ephemeral provenance. Only the SDK OAuth commit sets it;
11
+ // missing device state is never treated as evidence of an isolated session.
12
+ const isolatedSessions = new WeakMap<OxyRuntime, IsolatedOAuthSession>();
13
+
14
+ export function setIsolatedOAuthSession(runtime: OxyRuntime, session: IsolatedOAuthSession): void {
15
+ isolatedSessions.set(runtime, Object.freeze({ ...session }));
16
+ }
17
+
18
+ export function hasIsolatedOAuthSession(runtime: OxyRuntime): boolean {
19
+ return isolatedSessions.has(runtime);
20
+ }
21
+
22
+ export function clearIsolatedOAuthSession(runtime: OxyRuntime): void {
23
+ isolatedSessions.delete(runtime);
24
+ }
25
+
26
+ export function readIsolatedOAuthSession(runtime: OxyRuntime): IsolatedOAuthSession | null {
27
+ const session = isolatedSessions.get(runtime);
28
+ return session && session.sessionId === runtime.getSnapshot().activeSessionId ? session : null;
29
+ }
30
+
31
+ /** Exact self-revocation; never a fallback for an unknown device session. */
32
+ export async function logoutIsolatedOAuthSession(input: {
33
+ session: IsolatedOAuthSession;
34
+ targetSessionId?: string;
35
+ revokeSelf: (sessionId: string) => Promise<void>;
36
+ clearSessionState: () => Promise<void>;
37
+ }): Promise<LogoutResult> {
38
+ if (input.targetSessionId && input.targetSessionId !== input.session.sessionId) {
39
+ return { status: 'failed', error: new Error('An isolated OAuth session may sign out only itself') };
40
+ }
41
+ try {
42
+ await input.revokeSelf(input.session.sessionId);
43
+ } catch (error) {
44
+ if (!isInvalidSessionError(error)) return { status: 'failed', error };
45
+ }
46
+ await input.clearSessionState();
47
+ return { status: 'signed-out' };
48
+ }
@@ -1,3 +1,4 @@
1
+ import type { StartNativeOAuthSignInOptions, NativeOAuthSignInResult } from '../oauth/nativeAuthTransport';
1
2
  import type { ReactNode } from 'react';
2
3
  import type { LoginSessionResult } from '@oxy.so/contracts';
3
4
  import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, ApiError } from '@oxy.so/core';
@@ -106,6 +107,9 @@ export interface OxyContextState {
106
107
  * No-op outside a browser and in `sessionMode: 'identity'`, both of which
107
108
  * resolve to `{ status: 'unsupported' }`.
108
109
  */
110
+ /** Native SDK finalizer; call only from an explicit user gesture. */
111
+ startNativeOAuthSignIn: (options: StartNativeOAuthSignInOptions) => Promise<NativeOAuthSignInResult>;
112
+
109
113
  startWebOAuthSignIn: (options: StartWebOAuthSignInOptions) => Promise<WebOAuthSignInResult>;
110
114
 
111
115
  /**
@@ -16,6 +16,7 @@ interface UseOxyAccountGraphParams {
16
16
  * permanently. The graph is therefore never fetched and every switch rejects.
17
17
  */
18
18
  identityBound: boolean;
19
+ isolatedOAuth: boolean;
19
20
  oxyServices: OxyServices;
20
21
  sessionClient: SessionClient;
21
22
  syncFromClient: () => Promise<void>;
@@ -29,6 +30,7 @@ export function useOxyAccountGraph({
29
30
  tokenReady,
30
31
  initialized,
31
32
  identityBound,
33
+ isolatedOAuth,
32
34
  oxyServices,
33
35
  sessionClient,
34
36
  syncFromClient,
@@ -39,7 +41,7 @@ export function useOxyAccountGraph({
39
41
  const [accounts, setAccounts] = useState<AccountNode[]>([]);
40
42
 
41
43
  const refreshAccounts = useCallback(async (): Promise<void> => {
42
- if (identityBound || !isAuthenticated || !tokenReady || !oxyServices.session.accessToken) {
44
+ if (identityBound || isolatedOAuth || !isAuthenticated || !tokenReady || !oxyServices.session.accessToken) {
43
45
  setAccounts([]);
44
46
  return;
45
47
  }
@@ -56,17 +58,18 @@ export function useOxyAccountGraph({
56
58
  loggerUtil.debug('Failed to load accounts', { component: 'OxyContext' }, err as unknown);
57
59
  }
58
60
  }
59
- }, [identityBound, isAuthenticated, oxyServices, tokenReady, clearSessionStateRef]);
61
+ }, [identityBound, isolatedOAuth, isAuthenticated, oxyServices, tokenReady, clearSessionStateRef]);
60
62
 
61
63
  useEffect(() => {
62
- if (!identityBound && isAuthenticated && initialized && tokenReady) {
64
+ if (!identityBound && !isolatedOAuth && isAuthenticated && initialized && tokenReady) {
63
65
  refreshAccounts();
64
66
  void accountDialogControllerRef.current?.refresh();
65
67
  }
66
- }, [identityBound, isAuthenticated, initialized, tokenReady, refreshAccounts, accountDialogControllerRef]);
68
+ }, [identityBound, isolatedOAuth, isAuthenticated, initialized, tokenReady, refreshAccounts, accountDialogControllerRef]);
67
69
 
68
70
  const switchToAccount = useCallback(
69
71
  async (accountId: string): Promise<void> => {
72
+ if (isolatedOAuth) throw new Error('An isolated OAuth session cannot switch shared device accounts');
70
73
  if (identityBound) {
71
74
  // Loud rejection, never a silent no-op: a resolved promise here would
72
75
  // read to the caller as a completed switch that simply left the user
@@ -103,7 +106,7 @@ export function useOxyAccountGraph({
103
106
  // runs inside the runtime's subject transition, BEFORE any subscriber is
104
107
  // woken — so a socket-pushed switch (which never reached this function at
105
108
  // all) gets it too.
106
- [identityBound, oxyServices, sessionClient, syncFromClient, commitSession],
109
+ [identityBound, isolatedOAuth, oxyServices, sessionClient, syncFromClient, commitSession],
107
110
  );
108
111
 
109
112
  const createAccount = useCallback(
@@ -0,0 +1,23 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { authenticatedApiCall } from '@oxy.so/core';
3
+ import { useOxy } from '../../context/OxyContext';
4
+
5
+ /** Public discovery; deliberately outside persisted customer payment queries. */
6
+ export function usePersonalPlans() {
7
+ const { oxyServices } = useOxy();
8
+ return useQuery({ queryKey: ['personal-plan-catalogue'],
9
+ queryFn: () => oxyServices.billing.personalPlans(), staleTime: 0 });
10
+ }
11
+
12
+ /** Account and session partitioning plus a server-side expected-subject fence. */
13
+ export function usePersonalPlanSubscriptions() {
14
+ const { oxyServices, user, isAuthenticated, activeSessionId } = useOxy();
15
+ const accountId = user?.id;
16
+ return useQuery({ queryKey: ['personal-plan-sources', accountId, activeSessionId],
17
+ queryFn: () => {
18
+ if (!accountId) throw new Error('Authentication required');
19
+ return authenticatedApiCall(oxyServices, activeSessionId,
20
+ () => oxyServices.billing.productSubscriptions(accountId));
21
+ },
22
+ enabled: isAuthenticated && !!accountId, staleTime: 0, gcTime: 0 });
23
+ }
@@ -180,9 +180,12 @@ export function useAuth(): UseAuthReturn {
180
180
  showBottomSheet,
181
181
  openAvatarPicker,
182
182
  openAccountDialog,
183
+ ensureDeviceSessionLane,
183
184
  } = useOxyAuthActions();
184
185
 
185
186
  const signIn = useCallback(async (publicKey?: string): Promise<SignInOutcome> => {
187
+ const classification = ensureDeviceSessionLane();
188
+ if (classification) await classification;
186
189
  // Native: sign in directly with the cryptographic identity when a public key
187
190
  // is provided, or an existing keychain identity is found.
188
191
  if (publicKey) {
@@ -205,10 +208,11 @@ export function useAuth(): UseAuthReturn {
205
208
  // report the initiated UI action instead of leaking a promise that never settles.
206
209
  openAccountDialog('signin');
207
210
  return { status: 'dialog-opened' };
208
- }, [oxySignIn, hasIdentity, getPublicKey, openAccountDialog]);
211
+ }, [oxySignIn, hasIdentity, getPublicKey, openAccountDialog, ensureDeviceSessionLane]);
209
212
 
210
213
  const signOut = useCallback(async (): Promise<void> => {
211
- await logout();
214
+ const result = await logout();
215
+ if (result.status === 'failed') throw result.error;
212
216
  }, [logout]);
213
217
 
214
218
  const signOutAll = useCallback(async (): Promise<void> => {
@@ -41,7 +41,7 @@ describe('completeOAuthCode', () => {
41
41
  clientId: 'oxy_dk_test',
42
42
  redirectUri: REDIRECT_URI,
43
43
  codeVerifier: HANDSHAKE.codeVerifier,
44
- });
44
+ }, { plantTokens: false });
45
45
  expect(committed).toEqual([
46
46
  {
47
47
  sessionId: 'sess-1',
@@ -0,0 +1,57 @@
1
+ import type { OxyServices } from '@oxy.so/core';
2
+ import { startNativeOAuthSignIn, type NativeOAuthSignInContext } from '../nativeAuthTransport';
3
+ import { openAuthorizeUrlNative } from '../../components/oauthNavigation';
4
+ import { prepareAuthorizeRequest } from '../oauthHandshake';
5
+ jest.mock('../../components/oauthNavigation', () => ({ openAuthorizeUrlNative: jest.fn() }));
6
+ jest.mock('../oauthHandshake', () => ({ prepareAuthorizeRequest: jest.fn(async () => ({ authorizeUrl: 'https://auth.oxy.so/authorize', handshake: { state: 'expected', codeVerifier: 'verifier' } })) }));
7
+ const open = openAuthorizeUrlNative as jest.Mock;
8
+ const prepare = prepareAuthorizeRequest as jest.Mock;
9
+ const uri = 'externalapp://oauth/callback?tenant=fixed';
10
+ function fixture() {
11
+ const exchange = jest.fn(async () => ({ sessionId: 'isolated', accessToken: 'bearer', user: { id: 'user' } }));
12
+ const getPublic = jest.fn(async () => ({ id: 'app', name: 'External', type: 'third_party', isOfficial: false, isInternal: false, scopes: [] }));
13
+ const context: NativeOAuthSignInContext = { platform: 'native', oxyServices: { apps: { getPublic }, auth: { oauth: { exchangeCode: exchange } } } as unknown as OxyServices, clientId: 'oxy_dk_registered', identityBound: false, commitSession: jest.fn(async () => undefined) };
14
+ return { context, exchange, getPublic };
15
+ }
16
+ beforeEach(() => { jest.clearAllMocks(); open.mockResolvedValue({ redirectUrl: `${uri}&code=legitimate&state=expected` }); });
17
+ it('uses the common exchange and commits a fixed-query callback without device credentials', async () => {
18
+ const { context, exchange, getPublic } = fixture();
19
+ expect(await startNativeOAuthSignIn(context, { redirectUri: uri })).toEqual({ status: 'signed-in' });
20
+ expect(getPublic).toHaveBeenCalledWith('oxy_dk_registered', { cache: false });
21
+ expect(open).toHaveBeenCalledWith('https://auth.oxy.so/authorize', uri, { allowExternalFallback: false });
22
+ expect(exchange).toHaveBeenCalledWith({ code: 'legitimate', clientId: 'oxy_dk_registered', redirectUri: uri, codeVerifier: 'verifier' }, { plantTokens: false });
23
+ expect(context.commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'isolated', userId: 'user', deviceId: undefined, deviceSecret: undefined }));
24
+ });
25
+ it.each([
26
+ `${uri}&code=x&state=expected&code=y`, `${uri}&code=x&state=expected&state=expected`,
27
+ `${uri}&error=access_denied&state=wrong`, `${uri}&error=access_denied&state=expected`,
28
+ `${uri}&code=x&state=wrong`, `${uri}&tenant=other&code=x&state=expected`, 'externalapp://oauth/callback?tenant=other&code=x&state=expected',
29
+ `${uri}/extra?code=x&state=expected`, `${uri}&code=x&state=expected#fragment`,
30
+ ])('rejects %s before exchange/commit', async (redirectUrl) => {
31
+ const { context, exchange } = fixture(); open.mockResolvedValue({ redirectUrl });
32
+ expect((await startNativeOAuthSignIn(context, { redirectUri: uri })).status).toBe('failed');
33
+ expect(exchange).not.toHaveBeenCalled(); expect(context.commitSession).not.toHaveBeenCalled();
34
+ });
35
+ it('cancellation commits nothing', async () => {
36
+ const { context, exchange } = fixture(); open.mockResolvedValue({ redirectUrl: null });
37
+ expect(await startNativeOAuthSignIn(context, { redirectUri: uri })).toEqual({ status: 'cancelled' });
38
+ expect(exchange).not.toHaveBeenCalled(); expect(context.commitSession).not.toHaveBeenCalled();
39
+ });
40
+ it.each(['platform','client','identity','official','registry-error'])('direct method rejects %s before opening', async (mode) => {
41
+ const { context, exchange, getPublic } = fixture();
42
+ if(mode==='platform') context.platform='unsupported';
43
+ if(mode==='client') context.clientId=null;
44
+ if(mode==='identity') context.identityBound=true;
45
+ if(mode==='official') getPublic.mockResolvedValue({ id:'official', name:'Official', type:'first_party', isOfficial:true, isInternal:false, scopes:[] });
46
+ if(mode==='registry-error') getPublic.mockRejectedValue(new Error('unavailable'));
47
+ expect((await startNativeOAuthSignIn(context,{redirectUri:uri})).status).not.toBe('signed-in');
48
+ expect(open).not.toHaveBeenCalled(); expect(prepare).not.toHaveBeenCalled(); expect(exchange).not.toHaveBeenCalled();
49
+ });
50
+ it('rejects reentry without replacing the first handshake', async () => {
51
+ const { context }=fixture(); let cancel!: (result:{redirectUrl:null})=>void;
52
+ open.mockImplementation(()=>new Promise(resolve=>{cancel=resolve;}));
53
+ const first=startNativeOAuthSignIn(context,{redirectUri:uri});
54
+ for(let i=0;i<8;i++) await Promise.resolve();
55
+ expect(await startNativeOAuthSignIn(context,{redirectUri:uri})).toEqual({status:'failed',reason:'already-in-progress'});
56
+ cancel({redirectUrl:null}); await first;
57
+ });
@@ -0,0 +1,131 @@
1
+ /** @jest-environment node */
2
+
3
+ // Only the native browser transport is simulated. Metadata, token exchange and
4
+ // profile reads use the real OxyServices/HttpService against an owned HTTP server.
5
+ jest.mock('../../components/oauthNavigation', () => ({
6
+ openAuthorizeUrlNative: jest.fn(),
7
+ redirectToAuthorize: jest.fn(),
8
+ }));
9
+
10
+ import { createServer, type Server } from 'node:http';
11
+ import { OxyServices } from '@oxy.so/core';
12
+ import { openAuthorizeUrlNative } from '../../components/oauthNavigation';
13
+ import { requestOAuthConsent } from '../explicitOAuthConsent';
14
+
15
+ const PERSON = '01a0646a-078f-7000-8000-000000000001';
16
+ const ORGANIZATION = '01a0646a-078f-7000-8000-000000000002';
17
+ const CLIENT = 'oxy_dk_fixture';
18
+ const REDIRECT = 'fixture://oauth/consent';
19
+ const EXPIRES_AT = Math.floor(Date.now() / 1000) + 300;
20
+ function token(subject: string): string {
21
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
22
+ return `${encode({ alg: 'none' })}.${encode({ userId: subject, exp: EXPIRES_AT })}.fixture`;
23
+ }
24
+
25
+ describe('OAuth bearer commit with real HTTP transport', () => {
26
+ let server: Server;
27
+ let oxy: OxyServices;
28
+ let exchangeSubject: string;
29
+ let exchanges: number;
30
+ let profileSubjects: string[];
31
+ let unexpectedRequests: string[];
32
+ let visibleSubject: string;
33
+ const openNative = jest.mocked(openAuthorizeUrlNative);
34
+
35
+ beforeEach(async () => {
36
+ exchangeSubject = ORGANIZATION;
37
+ exchanges = 0;
38
+ profileSubjects = [];
39
+ unexpectedRequests = [];
40
+ visibleSubject = PERSON;
41
+ openNative.mockReset();
42
+ openNative.mockImplementation(async (authorizeUrl) => {
43
+ const state = new URL(authorizeUrl).searchParams.get('state');
44
+ return { redirectUrl: `${REDIRECT}?code=owned-code&state=${state}` };
45
+ });
46
+ server = createServer(async (req, res) => {
47
+ res.setHeader('Content-Type', 'application/json');
48
+ if (req.method === 'GET' && req.url === `/auth/oauth/client/${CLIENT}`) {
49
+ res.end(JSON.stringify({ application: { id: 'fixture-app', scopes: ['user:read'] } }));
50
+ } else if (req.method === 'POST' && req.url === '/auth/oauth/token') {
51
+ let body = '';
52
+ for await (const chunk of req) body += chunk;
53
+ const params = new URLSearchParams(body);
54
+ expect(params.get('client_id')).toBe(CLIENT);
55
+ expect(params.get('redirect_uri')).toBe(REDIRECT);
56
+ expect(params.get('code_verifier')).toBeTruthy();
57
+ exchanges += 1;
58
+ res.end(JSON.stringify({ access_token: token(exchangeSubject), session_id: 'fixture-session',
59
+ expires_in: 300, user: { id: exchangeSubject, username: exchangeSubject === PERSON ? 'fixture-person' : 'fixture-organization' } }));
60
+ } else if (req.method === 'GET' && req.url === '/users/me') {
61
+ const bearer = req.headers.authorization;
62
+ const subject = bearer === `Bearer ${token(PERSON)}` ? PERSON
63
+ : bearer === `Bearer ${token(ORGANIZATION)}` ? ORGANIZATION : null;
64
+ if (!subject) { res.statusCode = 401; res.end(JSON.stringify({ message: 'Unauthorized' })); return; }
65
+ profileSubjects.push(subject);
66
+ res.end(JSON.stringify({ id: subject, username: subject === PERSON ? 'fixture-person' : 'fixture-organization' }));
67
+ } else {
68
+ unexpectedRequests.push(`${req.method} ${req.url}`);
69
+ res.statusCode = 500; res.end(JSON.stringify({ message: 'Unexpected fixture request' }));
70
+ }
71
+ });
72
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
73
+ const address = server.address();
74
+ if (!address || typeof address === 'string') throw new Error('Owned HTTP listener missing');
75
+ oxy = new OxyServices({ baseURL: `http://127.0.0.1:${address.port}`, enableCache: false, maxRetries: 0 });
76
+ oxy.session.setAccessToken(token(PERSON));
77
+ });
78
+
79
+ afterEach(async () => {
80
+ oxy?.dispose();
81
+ server.closeAllConnections();
82
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
83
+ expect(unexpectedRequests).toEqual([]);
84
+ });
85
+
86
+ async function consent() {
87
+ return requestOAuthConsent({ platform: 'native', mode: 'popup', oxyServices: oxy,
88
+ clientId: CLIENT, identityBound: false, expectedUserId: PERSON,
89
+ commitSession: async (session) => {
90
+ if (!session.accessToken) throw new Error('Fixture commit requires bearer');
91
+ oxy.session.setAccessToken(session.accessToken);
92
+ visibleSubject = session.userId;
93
+ } }, { redirectUri: REDIRECT, scopes: ['user:read'] });
94
+ }
95
+
96
+ it('rejects a different returned subject without replacing the previous bearer', async () => {
97
+ const previous = oxy.session.accessToken;
98
+ expect(await consent()).toEqual({ status: 'failed', reason: 'subject-mismatch' });
99
+ expect(exchanges).toBe(1);
100
+ expect(visibleSubject).toBe(PERSON);
101
+ oxy.cache.clear();
102
+ expect((await oxy.users.me()).id).toBe(PERSON);
103
+ expect(profileSubjects).toEqual([PERSON]);
104
+ expect(oxy.session.accessToken).toBe(previous);
105
+ });
106
+
107
+ it('commits a matching subject and its new bearer after validation', async () => {
108
+ exchangeSubject = PERSON;
109
+ const changes: (string | null)[] = [];
110
+ const unsubscribe = oxy.session.onChange((value) => changes.push(value));
111
+ expect(await consent()).toEqual({ status: 'consented' });
112
+ unsubscribe();
113
+ expect(exchanges).toBe(1);
114
+ expect(changes).toEqual([token(PERSON)]);
115
+ expect((await oxy.users.me()).id).toBe(PERSON);
116
+ });
117
+
118
+ it('cancels before exchange and retains the bearer', async () => {
119
+ openNative.mockResolvedValue({ redirectUrl: null });
120
+ expect(await consent()).toEqual({ status: 'cancelled' });
121
+ expect(exchanges).toBe(0);
122
+ expect((await oxy.users.me()).id).toBe(PERSON);
123
+ });
124
+
125
+ it('preserves legacy direct exchange planting by default', async () => {
126
+ await oxy.auth.oauth.exchangeCode({ code: 'owned-code', clientId: CLIENT,
127
+ redirectUri: REDIRECT, codeVerifier: 'fixture-verifier' });
128
+ expect(exchanges).toBe(1);
129
+ expect((await oxy.users.me()).id).toBe(ORGANIZATION);
130
+ });
131
+ });
@@ -0,0 +1,26 @@
1
+ import { publicApplicationSchema } from '@oxy.so/contracts';
2
+ import type { OxyServices } from '@oxy.so/core';
3
+
4
+ export function classifyApplicationSessionLane(metadata: unknown): 'device' | 'oauth' {
5
+ const application = publicApplicationSchema.parse(metadata);
6
+ return application.type === 'third_party' && !application.isOfficial && !application.isInternal ? 'oauth' : 'device';
7
+ }
8
+
9
+ export const APPLICATION_CLASSIFICATION_DEADLINE_MS = 5000;
10
+
11
+ /** The active registry projection is authoritative; names and origins confer no trust. */
12
+ export async function resolveApplicationSessionLane(
13
+ oxyServices: OxyServices,
14
+ clientId: string | null | undefined,
15
+ ): Promise<'device' | 'oauth'> {
16
+ if (!clientId) throw new Error('A registered clientId is required');
17
+ // The public endpoint rejects inactive applications and unusable credentials.
18
+ let timer: ReturnType<typeof setTimeout> | undefined;
19
+ try {
20
+ const metadata = await Promise.race([
21
+ oxyServices.apps.getPublic(clientId, { cache: false }),
22
+ new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('Application classification timed out')), APPLICATION_CLASSIFICATION_DEADLINE_MS); }),
23
+ ]);
24
+ return classifyApplicationSessionLane(metadata);
25
+ } finally { if (timer !== undefined) clearTimeout(timer); }
26
+ }
@@ -9,10 +9,10 @@
9
9
  *
10
10
  * Ordering is deliberate and load-bearing:
11
11
  * 1. validate `state` BEFORE anything is sent to the token endpoint,
12
- * 2. exchange the code for a session,
12
+ * 2. exchange the code for session material without changing the bearer,
13
13
  * 3. run `cleanup` (drop the persisted handshake / strip `?code=` from the
14
14
  * URL) BEFORE the commit, so a stale code can never re-enter the exchange,
15
- * 4. commit the session.
15
+ * 4. validate and commit the session through the provider's funnel.
16
16
  *
17
17
  * `cleanup` runs exactly once on EVERY exit path, including a state mismatch
18
18
  * and a failed exchange.
@@ -73,7 +73,7 @@ export async function completeOAuthCode(
73
73
  clientId: input.clientId,
74
74
  redirectUri: input.redirectUri,
75
75
  codeVerifier: input.handshake.codeVerifier,
76
- });
76
+ }, { plantTokens: false });
77
77
  runCleanup();
78
78
  await input.commitSession({
79
79
  sessionId: result.sessionId,