@oxy.so/services 12.13.0 → 13.0.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/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxyConsentScreen.js +10 -7
- package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +116 -3
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/oauth/explicitOAuthConsent.js +10 -5
- package/lib/commonjs/ui/oauth/explicitOAuthConsent.js.map +1 -1
- package/lib/commonjs/ui/session/cachedBoot.js +95 -0
- package/lib/commonjs/ui/session/cachedBoot.js.map +1 -0
- package/lib/module/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/OxyConsentScreen.js +10 -7
- package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +117 -4
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/oauth/explicitOAuthConsent.js +10 -5
- package/lib/module/ui/oauth/explicitOAuthConsent.js.map +1 -1
- package/lib/module/ui/session/cachedBoot.js +92 -0
- package/lib/module/ui/session/cachedBoot.js.map +1 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +24 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +3 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +2 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +28 -50
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +13 -13
- package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts +108 -108
- package/lib/typescript/commonjs/ui/oauth/explicitOAuthConsent.d.ts +4 -6
- package/lib/typescript/commonjs/ui/oauth/explicitOAuthConsent.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts +48 -0
- package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts.map +1 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +24 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +3 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +2 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +28 -50
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +13 -13
- package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts +108 -108
- package/lib/typescript/module/ui/oauth/explicitOAuthConsent.d.ts +4 -6
- package/lib/typescript/module/ui/oauth/explicitOAuthConsent.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/cachedBoot.d.ts +48 -0
- package/lib/typescript/module/ui/session/cachedBoot.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
- package/src/ui/boot/runProviderColdBoot.ts +229 -95
- package/src/ui/components/OxyConsentScreen.tsx +40 -31
- package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +13 -0
- package/src/ui/context/OxyContext.tsx +146 -3
- package/src/ui/context/oxyContextTypes.ts +2 -1
- package/src/ui/oauth/__tests__/explicitOAuthConsent.test.ts +17 -0
- package/src/ui/oauth/explicitOAuthConsent.ts +10 -5
- package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
- package/src/ui/session/cachedBoot.ts +139 -0
|
@@ -73,6 +73,8 @@ export interface OxyConsentResource {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export interface OxyConsentScreenProps {
|
|
76
|
+
/** Presentation only: own sign-in has no permission grant. External MCP stays consent. */
|
|
77
|
+
mode?: 'consent' | 'sign-in';
|
|
76
78
|
/** The resolved requesting application. */
|
|
77
79
|
application: OxyConsentApplication;
|
|
78
80
|
/** OAuth scopes requested by the client (mapped to friendly labels). */
|
|
@@ -215,6 +217,7 @@ function ConsentLink({
|
|
|
215
217
|
|
|
216
218
|
export function OxyConsentScreen({
|
|
217
219
|
application,
|
|
220
|
+
mode = 'consent',
|
|
218
221
|
scopes,
|
|
219
222
|
user,
|
|
220
223
|
resource,
|
|
@@ -269,10 +272,12 @@ export function OxyConsentScreen({
|
|
|
269
272
|
</View>
|
|
270
273
|
</View>
|
|
271
274
|
<Text style={[styles.title, { color: theme.colors.text }]}>
|
|
272
|
-
{t('consent.title', { app: appName })}
|
|
275
|
+
{mode === 'sign-in' ? t('signin.title') : t('consent.title', { app: appName })}
|
|
273
276
|
</Text>
|
|
274
277
|
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
|
275
|
-
{
|
|
278
|
+
{mode === 'sign-in'
|
|
279
|
+
? t('signin.chooser.subtitleToApp', { app: appName })
|
|
280
|
+
: t('consent.subtitle', { app: appName })}
|
|
276
281
|
</Text>
|
|
277
282
|
</View>
|
|
278
283
|
|
|
@@ -333,36 +338,38 @@ export function OxyConsentScreen({
|
|
|
333
338
|
</View>
|
|
334
339
|
) : null}
|
|
335
340
|
|
|
336
|
-
{/*
|
|
337
|
-
|
|
338
|
-
<
|
|
339
|
-
{
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
permissionRows.
|
|
349
|
-
|
|
341
|
+
{/* Own sign-in has no permissions section, including the basic fallback. */}
|
|
342
|
+
{mode === 'consent' ? (
|
|
343
|
+
<View style={styles.section}>
|
|
344
|
+
<Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
|
|
345
|
+
{t('consent.permissions.title')}
|
|
346
|
+
</Text>
|
|
347
|
+
<View
|
|
348
|
+
style={[
|
|
349
|
+
styles.card,
|
|
350
|
+
{ borderColor: theme.colors.border, backgroundColor: theme.colors.card },
|
|
351
|
+
]}
|
|
352
|
+
>
|
|
353
|
+
{permissionRows.length > 0 ? (
|
|
354
|
+
permissionRows.map((row) => (
|
|
355
|
+
<View key={row.scope} testID={`consent-scope-${row.scope}`} style={styles.row}>
|
|
356
|
+
<View style={[styles.bullet, { backgroundColor: theme.colors.primary }]} />
|
|
357
|
+
<Text style={[styles.rowText, { color: theme.colors.text }]}>
|
|
358
|
+
{permissionRowLabel(row, t)}
|
|
359
|
+
</Text>
|
|
360
|
+
</View>
|
|
361
|
+
))
|
|
362
|
+
) : (
|
|
363
|
+
<View testID="consent-scope-basic" style={styles.row}>
|
|
350
364
|
<View style={[styles.bullet, { backgroundColor: theme.colors.primary }]} />
|
|
351
365
|
<Text style={[styles.rowText, { color: theme.colors.text }]}>
|
|
352
|
-
{
|
|
366
|
+
{t('consent.permissions.basic')}
|
|
353
367
|
</Text>
|
|
354
368
|
</View>
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
<View testID="consent-scope-basic" style={styles.row}>
|
|
358
|
-
<View style={[styles.bullet, { backgroundColor: theme.colors.primary }]} />
|
|
359
|
-
<Text style={[styles.rowText, { color: theme.colors.text }]}>
|
|
360
|
-
{t('consent.permissions.basic')}
|
|
361
|
-
</Text>
|
|
362
|
-
</View>
|
|
363
|
-
)}
|
|
369
|
+
)}
|
|
370
|
+
</View>
|
|
364
371
|
</View>
|
|
365
|
-
|
|
372
|
+
) : null}
|
|
366
373
|
|
|
367
374
|
{resource && resource.writeActions.length > 0 ? (
|
|
368
375
|
<View style={styles.section}>
|
|
@@ -479,7 +486,7 @@ export function OxyConsentScreen({
|
|
|
479
486
|
loading={busy}
|
|
480
487
|
style={styles.actionButton}
|
|
481
488
|
>
|
|
482
|
-
{t('consent.allow', { app: appName })}
|
|
489
|
+
{mode === 'sign-in' ? t('signin.actions.continue') : t('consent.allow', { app: appName })}
|
|
483
490
|
</Button>
|
|
484
491
|
<Button
|
|
485
492
|
testID="consent-deny"
|
|
@@ -493,9 +500,11 @@ export function OxyConsentScreen({
|
|
|
493
500
|
</Button>
|
|
494
501
|
</View>
|
|
495
502
|
|
|
496
|
-
|
|
497
|
-
{
|
|
498
|
-
|
|
503
|
+
{mode === 'consent' ? (
|
|
504
|
+
<Text style={[styles.disclaimer, { color: theme.colors.textSecondary }]}>
|
|
505
|
+
{t('consent.disclaimer', { app: appName })}
|
|
506
|
+
</Text>
|
|
507
|
+
) : null}
|
|
499
508
|
</ScrollView>
|
|
500
509
|
);
|
|
501
510
|
}
|
|
@@ -25,6 +25,7 @@ function renderScreen(props: Partial<OxyConsentScreenProps> = {}) {
|
|
|
25
25
|
scopes={props.scopes ?? ['openid', 'profile']}
|
|
26
26
|
user={props.user}
|
|
27
27
|
resource={props.resource}
|
|
28
|
+
mode={props.mode}
|
|
28
29
|
onAllow={props.onAllow ?? onAllow}
|
|
29
30
|
onDeny={props.onDeny ?? onDeny}
|
|
30
31
|
busy={props.busy}
|
|
@@ -36,6 +37,18 @@ function renderScreen(props: Partial<OxyConsentScreenProps> = {}) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
describe('OxyConsentScreen', () => {
|
|
40
|
+
it('renders own sign-in without any permission grant language or rows', () => {
|
|
41
|
+
const { queryByTestId, queryByText, getByTestId } = renderScreen({
|
|
42
|
+
mode: 'sign-in',
|
|
43
|
+
scopes: ['files:write'],
|
|
44
|
+
application: { name: 'Inbox', isOfficial: true },
|
|
45
|
+
});
|
|
46
|
+
expect(queryByTestId('consent-scope-files:write')).toBeNull();
|
|
47
|
+
expect(queryByTestId('consent-scope-basic')).toBeNull();
|
|
48
|
+
expect(queryByText('Permissions requested')).toBeNull();
|
|
49
|
+
expect(getByTestId('consent-allow').textContent).toContain('Continue');
|
|
50
|
+
});
|
|
51
|
+
|
|
39
52
|
it('renders every requested scope with a friendly label and falls back to the raw scope for unknown ones', () => {
|
|
40
53
|
const { getByTestId } = renderScreen({ scopes: ['openid', 'files:write', 'custom:thing'] });
|
|
41
54
|
|
|
@@ -59,7 +59,8 @@ import {
|
|
|
59
59
|
hasPersistedSessionCredential,
|
|
60
60
|
loadPersistedDeviceCredential,
|
|
61
61
|
} from '../utils/deviceCredential';
|
|
62
|
-
import { createTokenLossRecovery } from '../session/tokenLossRecovery';
|
|
62
|
+
import { createTokenLossRecovery, TOKEN_RECOVERY_BACKOFF_MS } from '../session/tokenLossRecovery';
|
|
63
|
+
import { createCachedBootStore, type CachedBootStore } from '../session/cachedBoot';
|
|
63
64
|
import { bindAuthStoreToRuntime } from '../stores/authStore';
|
|
64
65
|
import { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
65
66
|
import { useSessionManagement } from '../hooks/useSessionManagement';
|
|
@@ -222,8 +223,19 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
222
223
|
clientId: string | undefined;
|
|
223
224
|
services: OxyServices;
|
|
224
225
|
lane: 'device' | 'oauth' | null;
|
|
226
|
+
/**
|
|
227
|
+
* `lane` is this device's cached verdict, standing in until the registry
|
|
228
|
+
* answers (a painted cold boot). A fresh lookup keeps it meanwhile.
|
|
229
|
+
*/
|
|
230
|
+
provisional: boolean;
|
|
225
231
|
pending: Promise<'device' | 'oauth'> | null;
|
|
226
|
-
}>({
|
|
232
|
+
}>({
|
|
233
|
+
clientId: clientIdProp,
|
|
234
|
+
services: oxyServices,
|
|
235
|
+
lane: null,
|
|
236
|
+
provisional: false,
|
|
237
|
+
pending: null,
|
|
238
|
+
});
|
|
227
239
|
if (
|
|
228
240
|
registeredLaneRef.current.clientId !== clientIdProp ||
|
|
229
241
|
registeredLaneRef.current.services !== oxyServices
|
|
@@ -232,6 +244,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
232
244
|
clientId: clientIdProp,
|
|
233
245
|
services: oxyServices,
|
|
234
246
|
lane: null,
|
|
247
|
+
provisional: false,
|
|
235
248
|
pending: null,
|
|
236
249
|
};
|
|
237
250
|
}
|
|
@@ -372,6 +385,15 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
372
385
|
}
|
|
373
386
|
const storageReady = storageReadyRef.current;
|
|
374
387
|
|
|
388
|
+
// What a returning session's first paint needs (the app's classification and
|
|
389
|
+
// the account's profile), kept in the same storage as the account query cache.
|
|
390
|
+
const cachedBoot = useMemo(
|
|
391
|
+
() => (storage ? createCachedBootStore(storage, storageKeyPrefix) : null),
|
|
392
|
+
[storage, storageKeyPrefix],
|
|
393
|
+
);
|
|
394
|
+
const cachedBootRef = useRef<CachedBootStore | null>(cachedBoot);
|
|
395
|
+
cachedBootRef.current = cachedBoot;
|
|
396
|
+
|
|
375
397
|
useEffect(() => {
|
|
376
398
|
// `null` means the public provider owns initialization and has not resolved
|
|
377
399
|
// it yet. `undefined` means this internal provider was mounted directly and
|
|
@@ -535,11 +557,17 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
535
557
|
if (selected.clientId !== clientIdProp || selected.services !== oxyServices)
|
|
536
558
|
throw new Error('Provider classification was superseded');
|
|
537
559
|
if (selected.pending) return selected.pending;
|
|
538
|
-
selected.lane = null;
|
|
560
|
+
if (!selected.provisional) selected.lane = null;
|
|
539
561
|
const pending = (async () => {
|
|
540
562
|
const lane = await resolveApplicationSessionLane(oxyServices, clientIdProp);
|
|
541
563
|
if (registeredLaneRef.current !== selected)
|
|
542
564
|
throw new Error('Provider classification was superseded');
|
|
565
|
+
if (clientIdProp) void cachedBootRef.current?.saveLane(clientIdProp, lane);
|
|
566
|
+
if (selected.provisional) {
|
|
567
|
+
// The registry has answered: its verdict replaces the cached one now.
|
|
568
|
+
selected.provisional = false;
|
|
569
|
+
selected.lane = lane;
|
|
570
|
+
}
|
|
543
571
|
if (lane === 'oauth' && !hasIsolatedOAuthSession(runtime)) {
|
|
544
572
|
sessionClient.resetLocalState();
|
|
545
573
|
sessionClientHost.setDeviceCredential(null);
|
|
@@ -1284,6 +1312,115 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1284
1312
|
if (!accountDialogOpen) clearSignInFlows(accountDialogControllerRef.current);
|
|
1285
1313
|
}, [accountDialogOpen]);
|
|
1286
1314
|
|
|
1315
|
+
// ── Cached first paint ───────────────────────────────────────────────────
|
|
1316
|
+
// Keep the profile the runtime holds for the signed-in account, so the next
|
|
1317
|
+
// cold boot can paint it before the network answers. Only for a device-lane
|
|
1318
|
+
// account session: an identity-bound client and an isolated OAuth session
|
|
1319
|
+
// always boot through the network. A sign-out deletes it.
|
|
1320
|
+
const cachedAccountJsonRef = useRef<string | null>(null);
|
|
1321
|
+
useEffect(() => {
|
|
1322
|
+
if (!cachedBoot || identity) return;
|
|
1323
|
+
if (user === null) {
|
|
1324
|
+
if (cachedAccountJsonRef.current !== null) {
|
|
1325
|
+
cachedAccountJsonRef.current = null;
|
|
1326
|
+
void cachedBoot.clearAccount();
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime)) return;
|
|
1331
|
+
const json = JSON.stringify(user);
|
|
1332
|
+
if (json === cachedAccountJsonRef.current) return;
|
|
1333
|
+
cachedAccountJsonRef.current = json;
|
|
1334
|
+
void cachedBoot.saveAccount(user);
|
|
1335
|
+
}, [cachedBoot, identity, user, runtime]);
|
|
1336
|
+
|
|
1337
|
+
// Paint the session saved on this device: the bearer it left (when still
|
|
1338
|
+
// valid), the session mirror, the account from its cached profile, auth
|
|
1339
|
+
// resolved. Local reads only; the boot checks it all in the background.
|
|
1340
|
+
const paintCachedSession = useCallback(async (): Promise<boolean> => {
|
|
1341
|
+
const cache = cachedBootRef.current;
|
|
1342
|
+
if (identity || !clientIdProp || !cache) return false;
|
|
1343
|
+
const cached = await cache.readSession(clientIdProp, authStore);
|
|
1344
|
+
const selected = registeredLaneRef.current;
|
|
1345
|
+
if (
|
|
1346
|
+
!cached ||
|
|
1347
|
+
selected.clientId !== clientIdProp ||
|
|
1348
|
+
selected.services !== oxyServices ||
|
|
1349
|
+
selected.lane === 'oauth'
|
|
1350
|
+
) {
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
if (selected.lane === null) {
|
|
1354
|
+
// The cached verdict stands in for the registry until it answers, so the
|
|
1355
|
+
// refresh handler, lost-token recovery and sign-in treat this app as the
|
|
1356
|
+
// device lane it was on last run.
|
|
1357
|
+
selected.lane = 'device';
|
|
1358
|
+
selected.provisional = true;
|
|
1359
|
+
}
|
|
1360
|
+
if (cached.warmToken) oxyServices.session.setAccessToken(cached.warmToken);
|
|
1361
|
+
await syncDeviceCredentialToHost();
|
|
1362
|
+
const now = new Date();
|
|
1363
|
+
runtime.batch(() => {
|
|
1364
|
+
runtime.mergeSessions(
|
|
1365
|
+
[
|
|
1366
|
+
{
|
|
1367
|
+
sessionId: cached.sessionId,
|
|
1368
|
+
deviceId: cached.deviceId,
|
|
1369
|
+
expiresAt: new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
|
|
1370
|
+
lastActive: now.toISOString(),
|
|
1371
|
+
userId: cached.userId,
|
|
1372
|
+
isCurrent: true,
|
|
1373
|
+
},
|
|
1374
|
+
],
|
|
1375
|
+
{ merge: true },
|
|
1376
|
+
);
|
|
1377
|
+
runtime.setActiveSessionId(cached.sessionId);
|
|
1378
|
+
runtime.setAccount(cached.user);
|
|
1379
|
+
});
|
|
1380
|
+
onAuthStateChangeRef.current?.(cached.user);
|
|
1381
|
+
markAuthResolvedRef.current();
|
|
1382
|
+
return true;
|
|
1383
|
+
}, [identity, clientIdProp, authStore, oxyServices, runtime, syncDeviceCredentialToHost]);
|
|
1384
|
+
|
|
1385
|
+
// A painted session the server could not be asked about yet stays on screen
|
|
1386
|
+
// and is asked again: on the lost-token backoff, and at once when the device
|
|
1387
|
+
// comes back online. Signed out meanwhile, there is nothing left to ask.
|
|
1388
|
+
const cachedSessionCheckRef = useRef<{
|
|
1389
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
1390
|
+
attempt: number;
|
|
1391
|
+
runNow: (() => void) | null;
|
|
1392
|
+
}>({ timer: null, attempt: 0, runNow: null });
|
|
1393
|
+
const scheduleCachedSessionCheck = useCallback(
|
|
1394
|
+
(retry: () => Promise<void>): void => {
|
|
1395
|
+
const check = cachedSessionCheckRef.current;
|
|
1396
|
+
if (check.timer !== null) clearTimeout(check.timer);
|
|
1397
|
+
const run = (): void => {
|
|
1398
|
+
if (check.timer !== null) clearTimeout(check.timer);
|
|
1399
|
+
check.timer = null;
|
|
1400
|
+
check.runNow = null;
|
|
1401
|
+
if (runtime.getSnapshot().account === null) return;
|
|
1402
|
+
retry().catch((retryError) => {
|
|
1403
|
+
logger('Failed to check the cached session', retryError);
|
|
1404
|
+
});
|
|
1405
|
+
};
|
|
1406
|
+
const delay =
|
|
1407
|
+
TOKEN_RECOVERY_BACKOFF_MS[Math.min(check.attempt, TOKEN_RECOVERY_BACKOFF_MS.length - 1)];
|
|
1408
|
+
check.attempt += 1;
|
|
1409
|
+
check.runNow = run;
|
|
1410
|
+
check.timer = setTimeout(run, delay);
|
|
1411
|
+
},
|
|
1412
|
+
[runtime, logger],
|
|
1413
|
+
);
|
|
1414
|
+
useEffect(
|
|
1415
|
+
() => () => {
|
|
1416
|
+
const check = cachedSessionCheckRef.current;
|
|
1417
|
+
if (check.timer !== null) clearTimeout(check.timer);
|
|
1418
|
+
check.timer = null;
|
|
1419
|
+
check.runNow = null;
|
|
1420
|
+
},
|
|
1421
|
+
[],
|
|
1422
|
+
);
|
|
1423
|
+
|
|
1287
1424
|
// ── Cold boot ────────────────────────────────────────────────────────────
|
|
1288
1425
|
// Device-first session restore via `runProviderColdBoot` (see boot/runProviderColdBoot.ts).
|
|
1289
1426
|
const runColdBoot = useCallback(async (): Promise<void> => {
|
|
@@ -1314,6 +1451,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1314
1451
|
commitSession: (input, options) => commitSessionRef.current(input, options),
|
|
1315
1452
|
markAuthResolved: () => markAuthResolvedRef.current(),
|
|
1316
1453
|
setTokenReady,
|
|
1454
|
+
paintCachedSession,
|
|
1455
|
+
onCachedSessionRejected: () => clearSessionStateRef.current(),
|
|
1456
|
+
onCachedSessionUnconfirmed: scheduleCachedSessionCheck,
|
|
1317
1457
|
});
|
|
1318
1458
|
// The boot may have (re-)established the identity session or cleared a pin
|
|
1319
1459
|
// whose key no longer exists, so re-resolve rather than trusting the memo
|
|
@@ -1329,6 +1469,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1329
1469
|
syncDeviceCredentialToHost,
|
|
1330
1470
|
sessionClient,
|
|
1331
1471
|
setTokenReady,
|
|
1472
|
+
paintCachedSession,
|
|
1473
|
+
scheduleCachedSessionCheck,
|
|
1332
1474
|
]);
|
|
1333
1475
|
|
|
1334
1476
|
useEffect(() => {
|
|
@@ -1427,6 +1569,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
1427
1569
|
if (!online || !wasOffline) {
|
|
1428
1570
|
return;
|
|
1429
1571
|
}
|
|
1572
|
+
cachedSessionCheckRef.current.runNow?.();
|
|
1430
1573
|
void (async () => {
|
|
1431
1574
|
if (hasIsolatedOAuthSession(runtime)) return;
|
|
1432
1575
|
if (oxyServices.session.accessToken || !sessionClientHost.getDeviceCredential()) {
|
|
@@ -129,7 +129,8 @@ export interface OxyContextState {
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* Ask the already-authenticated user for explicit OAuth consent to exact
|
|
132
|
-
* application scopes.
|
|
132
|
+
* external application scopes. Own Oxy apps do not use this operation.
|
|
133
|
+
* Must be called from a user gesture. Works on web and
|
|
133
134
|
* native and commits only a callback bound to the same user, state, PKCE pair
|
|
134
135
|
* and byte-exact redirect URI.
|
|
135
136
|
*/
|
|
@@ -82,6 +82,23 @@ describe('requestOAuthConsent', () => {
|
|
|
82
82
|
jest.restoreAllMocks();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it.each(['first_party', 'internal', 'system'])(
|
|
86
|
+
'does not request consent for an own %s application',
|
|
87
|
+
async (type) => {
|
|
88
|
+
const context = makeContext('native');
|
|
89
|
+
const apps = context.oxyServices.apps.getPublic as jest.Mock;
|
|
90
|
+
apps.mockResolvedValue({ id: 'own-app', type, scopes: [...EXACT_SCOPES] });
|
|
91
|
+
expect(
|
|
92
|
+
await requestOAuthConsent(context, {
|
|
93
|
+
redirectUri: NATIVE_REDIRECT_URI,
|
|
94
|
+
scopes: EXACT_SCOPES,
|
|
95
|
+
}),
|
|
96
|
+
).toEqual({ status: 'unsupported', reason: 'first-party-application' });
|
|
97
|
+
expect(openNative).not.toHaveBeenCalled();
|
|
98
|
+
expect(context.commitSession).not.toHaveBeenCalled();
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
85
102
|
it('uses the existing web popup transport with exact scopes and redirect', async () => {
|
|
86
103
|
const popup = fakePopup();
|
|
87
104
|
jest.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Explicit OAuth consent for an already-authenticated Oxy user.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* caller must invoke this operation from a real user gesture; it never runs on
|
|
8
|
-
* mount, retries silently, or grants anything locally.
|
|
4
|
+
* External applications may request additional scopes through OAuth. Own Oxy
|
|
5
|
+
* applications use the account dialog and never create OAuth consent grants.
|
|
6
|
+
* The caller invokes this operation from a user gesture.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import type { OxyServices, PublicApplication } from "@oxy.so/core";
|
|
@@ -44,6 +42,7 @@ export type OAuthConsentFailureReason =
|
|
|
44
42
|
|
|
45
43
|
export type OAuthConsentUnsupportedReason =
|
|
46
44
|
| "missing-client-id"
|
|
45
|
+
| "first-party-application"
|
|
47
46
|
| "identity-bound"
|
|
48
47
|
| "not-authenticated"
|
|
49
48
|
| "unsupported-platform";
|
|
@@ -173,6 +172,12 @@ export async function requestOAuthConsent(
|
|
|
173
172
|
return { status: "failed", reason: "client-resolution-failed" };
|
|
174
173
|
}
|
|
175
174
|
|
|
175
|
+
if (application.isOfficial || application.isInternal ||
|
|
176
|
+
["first_party", "internal", "system"].includes(application.type)) {
|
|
177
|
+
closeOAuthPopup(popup);
|
|
178
|
+
return { status: "unsupported", reason: "first-party-application" };
|
|
179
|
+
}
|
|
180
|
+
|
|
176
181
|
const configuredScopes = new Set(application.scopes);
|
|
177
182
|
if (options.scopes.some((scope) => !configuredScopes.has(scope))) {
|
|
178
183
|
closeOAuthPopup(popup);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { User } from '@oxy.so/core';
|
|
2
|
+
import type { AuthStateStore, PersistedAuthState } from '@oxy.so/core/session';
|
|
3
|
+
import { createCachedBootStore } from '../cachedBoot';
|
|
4
|
+
import { createMemoryStorage } from '../../utils/storageHelpers';
|
|
5
|
+
|
|
6
|
+
function authStore(state: PersistedAuthState): AuthStateStore {
|
|
7
|
+
return {
|
|
8
|
+
load: async () => state,
|
|
9
|
+
save: async () => true,
|
|
10
|
+
clear: async () => undefined,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
it('offers a cached session only for the account the saved session belongs to', async () => {
|
|
15
|
+
const cache = createCachedBootStore(createMemoryStorage(), 'oxy_session');
|
|
16
|
+
const saved = { sessionId: 's1', userId: 'u1', deviceId: 'd1', deviceSecret: 'secret' };
|
|
17
|
+
await cache.saveLane('client', 'device');
|
|
18
|
+
await cache.saveAccount({ id: 'u2', username: 'other' } as User);
|
|
19
|
+
|
|
20
|
+
expect(await cache.readSession('client', authStore(saved))).toBeNull();
|
|
21
|
+
|
|
22
|
+
await cache.saveAccount({ id: 'u1', username: 'me' } as User);
|
|
23
|
+
expect(await cache.readSession('client', authStore(saved))).toMatchObject({
|
|
24
|
+
sessionId: 's1',
|
|
25
|
+
user: { id: 'u1' },
|
|
26
|
+
warmToken: null,
|
|
27
|
+
});
|
|
28
|
+
// Another app's classification, or an OAuth-only one, never paints.
|
|
29
|
+
expect(await cache.readSession('another-client', authStore(saved))).toBeNull();
|
|
30
|
+
await cache.saveLane('client', 'oauth');
|
|
31
|
+
expect(await cache.readSession('client', authStore(saved))).toBeNull();
|
|
32
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a returning session needs for its first paint, kept on this device.
|
|
3
|
+
*
|
|
4
|
+
* A cold boot used to wait on two round trips before it showed anything: the
|
|
5
|
+
* registry's classification of this app and the profile of the signed-in
|
|
6
|
+
* account. With a session saved on the device, the app now paints at once from
|
|
7
|
+
* what the last run left here, and the boot verifies both in the background
|
|
8
|
+
* (`runProviderColdBoot`). This module is that cache:
|
|
9
|
+
*
|
|
10
|
+
* - the registry's last verdict on this app (`device` or `oauth`), tied to the
|
|
11
|
+
* `clientId` it was given for;
|
|
12
|
+
* - the last account profile the runtime held, tied to that account's id.
|
|
13
|
+
*
|
|
14
|
+
* Both live in the provider's ordinary storage (AsyncStorage on native,
|
|
15
|
+
* `localStorage` on web), next to the per-account query cache, which already
|
|
16
|
+
* holds the same profile under the SDK's `users` queries. Neither is a
|
|
17
|
+
* credential: the device secret and the access token stay in the auth store.
|
|
18
|
+
*
|
|
19
|
+
* A cached session is offered only when it matches the auth store: the profile
|
|
20
|
+
* must belong to the account the persisted session is for, so a switch or a
|
|
21
|
+
* sign-out on a previous run can never paint somebody else.
|
|
22
|
+
*/
|
|
23
|
+
import type { User } from '@oxy.so/core';
|
|
24
|
+
import { TOKEN_REFRESH_LEAD_MS, type AuthStateStore } from '@oxy.so/core/session';
|
|
25
|
+
import type { StorageInterface } from '../utils/storageHelpers';
|
|
26
|
+
|
|
27
|
+
export type SessionLane = 'device' | 'oauth';
|
|
28
|
+
|
|
29
|
+
/** A persisted session the app can show before the network answers. */
|
|
30
|
+
export interface CachedSession {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
deviceId: string;
|
|
34
|
+
/** The account profile the last run held for {@link userId}. */
|
|
35
|
+
user: User;
|
|
36
|
+
/** The persisted access token, when it is still valid past the refresh lead. */
|
|
37
|
+
warmToken: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CachedBootStore {
|
|
41
|
+
saveLane(clientId: string, lane: SessionLane): Promise<void>;
|
|
42
|
+
saveAccount(user: User): Promise<void>;
|
|
43
|
+
clearAccount(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* The cached session for this `clientId`, or `null` when anything a first
|
|
46
|
+
* paint needs is missing or does not match the auth store.
|
|
47
|
+
*/
|
|
48
|
+
readSession(clientId: string, authStore: AuthStateStore): Promise<CachedSession | null>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SCHEMA_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
function parse(raw: string | null): Record<string, unknown> | null {
|
|
54
|
+
if (!raw) return null;
|
|
55
|
+
try {
|
|
56
|
+
const value: unknown = JSON.parse(raw);
|
|
57
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isUser(value: unknown): value is User {
|
|
64
|
+
return (
|
|
65
|
+
Boolean(value) &&
|
|
66
|
+
typeof value === 'object' &&
|
|
67
|
+
typeof (value as { id?: unknown }).id === 'string' &&
|
|
68
|
+
(value as { id: string }).id.length > 0
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createCachedBootStore(
|
|
73
|
+
storage: StorageInterface,
|
|
74
|
+
prefix: string,
|
|
75
|
+
now: () => number = Date.now,
|
|
76
|
+
): CachedBootStore {
|
|
77
|
+
const laneKey = `${prefix}_boot_lane`;
|
|
78
|
+
const accountKey = `${prefix}_boot_account`;
|
|
79
|
+
// Storage failures are never fatal here: a missing entry only means the next
|
|
80
|
+
// boot waits for the network, as every boot did before this cache existed.
|
|
81
|
+
const write = (key: string, value: unknown) =>
|
|
82
|
+
storage.setItem(key, JSON.stringify({ v: SCHEMA_VERSION, ...(value as object) })).catch(() => {
|
|
83
|
+
/* best effort */
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
saveLane: (clientId, lane) => write(laneKey, { clientId, lane }),
|
|
88
|
+
saveAccount: (user) => write(accountKey, { user }),
|
|
89
|
+
clearAccount: () =>
|
|
90
|
+
storage.removeItem(accountKey).catch(() => {
|
|
91
|
+
/* best effort */
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
async readSession(clientId, authStore) {
|
|
95
|
+
const [laneEntry, accountEntry] = await Promise.all([
|
|
96
|
+
storage.getItem(laneKey).then(parse, () => null),
|
|
97
|
+
storage.getItem(accountKey).then(parse, () => null),
|
|
98
|
+
]);
|
|
99
|
+
if (
|
|
100
|
+
laneEntry?.v !== SCHEMA_VERSION ||
|
|
101
|
+
laneEntry.clientId !== clientId ||
|
|
102
|
+
laneEntry.lane !== 'device'
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const user = accountEntry?.v === SCHEMA_VERSION ? accountEntry.user : null;
|
|
107
|
+
if (!isUser(user)) return null;
|
|
108
|
+
|
|
109
|
+
const persisted = await authStore.load();
|
|
110
|
+
// Only the app's OWN holder: the background check mints with it, which is
|
|
111
|
+
// the only way the server can confirm or end this session.
|
|
112
|
+
if (
|
|
113
|
+
!persisted?.sessionId ||
|
|
114
|
+
!persisted.deviceId ||
|
|
115
|
+
!persisted.deviceSecret ||
|
|
116
|
+
persisted.userId !== user.id
|
|
117
|
+
) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
// A recorded sign-out on this device restores nothing automatically.
|
|
121
|
+
if (await authStore.isAutomaticIdentitySignInSuppressed?.()) return null;
|
|
122
|
+
|
|
123
|
+
const expiresAtMs = persisted.expiresAt ? new Date(persisted.expiresAt).getTime() : NaN;
|
|
124
|
+
const warmToken =
|
|
125
|
+
persisted.accessToken &&
|
|
126
|
+
Number.isFinite(expiresAtMs) &&
|
|
127
|
+
expiresAtMs > now() + TOKEN_REFRESH_LEAD_MS
|
|
128
|
+
? persisted.accessToken
|
|
129
|
+
: null;
|
|
130
|
+
return {
|
|
131
|
+
sessionId: persisted.sessionId,
|
|
132
|
+
userId: persisted.userId,
|
|
133
|
+
deviceId: persisted.deviceId,
|
|
134
|
+
user,
|
|
135
|
+
warmToken,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|