@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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ui/hooks/useAuth.ts
CHANGED
|
@@ -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> => {
|
|
@@ -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
|
|
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,
|