@oxyhq/core 20.0.0 → 21.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/NOTICE +10 -9
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.utility.js +159 -104
- package/dist/cjs/mixins/index.js +7 -0
- package/dist/cjs/server/rateLimit.js +15 -6
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.utility.js +159 -104
- package/dist/esm/mixins/index.js +7 -0
- package/dist/esm/server/rateLimit.js +15 -6
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +15 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +105 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.utility.ts +161 -108
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/index.ts +9 -0
- package/src/models/session.ts +11 -0
- package/src/server/__tests__/rateLimit.test.ts +47 -0
- package/src/server/rateLimit.ts +18 -8
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -250,18 +250,41 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
250
250
|
* Uses server-side session validation for security (not just JWT decode).
|
|
251
251
|
*
|
|
252
252
|
* **Design note — jwtDecode vs jwt.verify:**
|
|
253
|
-
* This middleware
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* -
|
|
257
|
-
*
|
|
258
|
-
* -
|
|
259
|
-
*
|
|
260
|
-
*
|
|
253
|
+
* This middleware uses `jwtDecode()` (decode-only, NO signature check) for
|
|
254
|
+
* user tokens, because third-party apps mounting `oxy.auth()` do not hold
|
|
255
|
+
* the Oxy signing secret. **Every claim in a user token is therefore
|
|
256
|
+
* attacker-controlled and proves nothing on its own.** The identity comes
|
|
257
|
+
* from somewhere else entirely:
|
|
258
|
+
* - A user token MUST carry a `sessionId`. That session is validated
|
|
259
|
+
* server-side on every request via `validateSession()`, and the user id
|
|
260
|
+
* is read off the VALIDATED SESSION — never off the token. A token whose
|
|
261
|
+
* `userId` claim disagrees with the session is refused
|
|
262
|
+
* (`SESSION_USER_MISMATCH`); a token with no `sessionId` at all is
|
|
263
|
+
* refused outright (`SESSION_REQUIRED`). There is no local-claims path.
|
|
264
|
+
* - Service tokens (type: 'service') ARE stateless, so they use
|
|
265
|
+
* cryptographic HMAC verification via the `jwtSecret` option, and are
|
|
266
|
+
* additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
261
267
|
* cross-token-type confusion attacks.
|
|
262
268
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
263
269
|
* direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
|
|
264
270
|
*
|
|
271
|
+
* **Why session-less user tokens are refused rather than trusted:**
|
|
272
|
+
* every user access token the Oxy API issues carries a `sessionId` (see
|
|
273
|
+
* `packages/api/src/utils/sessionUtils.ts`, `generateSessionTokens` — the
|
|
274
|
+
* only mint site for user tokens, including the OAuth code exchange). So
|
|
275
|
+
* refusing session-less user tokens costs nothing legitimate, while
|
|
276
|
+
* accepting them let anyone authenticate as anyone by hand-rolling a JWT
|
|
277
|
+
* with a `userId` claim and a garbage signature.
|
|
278
|
+
*
|
|
279
|
+
* **Why the claimed user id is cross-checked against the session:**
|
|
280
|
+
* `GET /session/validate/:sessionId` is UNAUTHENTICATED and does not bind
|
|
281
|
+
* the bearer token — it returns whoever owns the session id it was handed.
|
|
282
|
+
* Trusting the token's `userId` claim after a successful validation would
|
|
283
|
+
* therefore let a caller holding ANY live session id (their own, for
|
|
284
|
+
* instance) pair it with a forged `userId` and be trusted as that user.
|
|
285
|
+
* `authSocket()` has always cross-checked this; the HTTP middleware now
|
|
286
|
+
* does too.
|
|
287
|
+
*
|
|
265
288
|
* **Service-token delegation (X-Oxy-User-Id):**
|
|
266
289
|
* When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
|
|
267
290
|
* `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
|
|
@@ -543,8 +566,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
543
566
|
return next();
|
|
544
567
|
}
|
|
545
568
|
|
|
546
|
-
|
|
547
|
-
|
|
569
|
+
// The CLAIMED user id. Never trusted as an identity — it is only ever
|
|
570
|
+
// compared against the id the validated session resolves to.
|
|
571
|
+
const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
|
|
572
|
+
if (!claimedUserId) {
|
|
548
573
|
if (optional) {
|
|
549
574
|
req.userId = null;
|
|
550
575
|
req.user = null;
|
|
@@ -580,58 +605,82 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
580
605
|
return res.status(401).json(error);
|
|
581
606
|
}
|
|
582
607
|
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
608
|
+
// A server-validated session is MANDATORY for a user token. The JWT
|
|
609
|
+
// signature is not verified on this path, so a bare decoded token
|
|
610
|
+
// proves nothing: without the session round-trip a forged token could
|
|
611
|
+
// claim any user id. Mirrors `authSocket()`, which has always
|
|
612
|
+
// required this.
|
|
613
|
+
const sessionId = readStringClaim(decoded.sessionId);
|
|
614
|
+
if (!sessionId) {
|
|
615
|
+
if (optional) {
|
|
616
|
+
req.userId = null;
|
|
617
|
+
req.user = null;
|
|
618
|
+
return next();
|
|
619
|
+
}
|
|
590
620
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
621
|
+
const error = {
|
|
622
|
+
error: 'SESSION_REQUIRED',
|
|
623
|
+
message: 'Access token is not bound to a session',
|
|
624
|
+
code: 'SESSION_REQUIRED',
|
|
625
|
+
status: 401
|
|
626
|
+
};
|
|
627
|
+
if (onError) return onError(error);
|
|
628
|
+
return res.status(401).json(error);
|
|
629
|
+
}
|
|
597
630
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
631
|
+
// Validate the token against the Oxy API. This proves the session is
|
|
632
|
+
// real and unrevoked, AND yields the identity it belongs to.
|
|
633
|
+
try {
|
|
634
|
+
const validationResult = await oxyInstance.validateSession(sessionId, {
|
|
635
|
+
useHeaderValidation: true,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (!validationResult || !validationResult.valid || !validationResult.user) {
|
|
639
|
+
if (optional) {
|
|
640
|
+
req.userId = null;
|
|
641
|
+
req.user = null;
|
|
642
|
+
return next();
|
|
606
643
|
}
|
|
607
644
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
645
|
+
const error = {
|
|
646
|
+
error: 'INVALID_SESSION',
|
|
647
|
+
message: 'Session invalid or expired',
|
|
648
|
+
code: 'INVALID_SESSION',
|
|
649
|
+
status: 401
|
|
650
|
+
};
|
|
651
|
+
if (onError) return onError(error);
|
|
652
|
+
return res.status(401).json(error);
|
|
653
|
+
}
|
|
612
654
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
req.
|
|
655
|
+
// The session — not the token — is the source of truth for identity.
|
|
656
|
+
const validatedUserId = getUserIdentityId(validationResult.user);
|
|
657
|
+
if (!validatedUserId) {
|
|
658
|
+
if (optional) {
|
|
659
|
+
req.userId = null;
|
|
660
|
+
req.user = null;
|
|
661
|
+
return next();
|
|
618
662
|
}
|
|
619
663
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
664
|
+
const error = {
|
|
665
|
+
error: 'INVALID_SESSION',
|
|
666
|
+
message: 'Session did not resolve to a usable identity',
|
|
667
|
+
code: 'INVALID_SESSION',
|
|
668
|
+
status: 401
|
|
669
|
+
};
|
|
670
|
+
if (onError) return onError(error);
|
|
671
|
+
return res.status(401).json(error);
|
|
672
|
+
}
|
|
626
673
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
674
|
+
if (validatedUserId !== claimedUserId) {
|
|
675
|
+
// Session-id/claim confusion: the caller presented a live session
|
|
676
|
+
// that belongs to somebody else. Worth a warning — it has no
|
|
677
|
+
// benign cause. Ids only; never the token or the payload.
|
|
678
|
+
logger.warn('[oxy.auth] Token rejected — claimed user does not own the session', {
|
|
679
|
+
component: 'auth',
|
|
680
|
+
method: 'auth',
|
|
681
|
+
claimedUserId,
|
|
682
|
+
validatedUserId,
|
|
683
|
+
});
|
|
635
684
|
|
|
636
685
|
if (optional) {
|
|
637
686
|
req.userId = null;
|
|
@@ -640,58 +689,53 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
640
689
|
}
|
|
641
690
|
|
|
642
691
|
const error = {
|
|
643
|
-
error: '
|
|
644
|
-
message: '
|
|
645
|
-
code: '
|
|
692
|
+
error: 'SESSION_USER_MISMATCH',
|
|
693
|
+
message: 'Token user does not match the session',
|
|
694
|
+
code: 'SESSION_USER_MISMATCH',
|
|
646
695
|
status: 401
|
|
647
696
|
};
|
|
648
697
|
if (onError) return onError(error);
|
|
649
698
|
return res.status(401).json(error);
|
|
650
699
|
}
|
|
651
|
-
}
|
|
652
700
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
701
|
+
req.userId = validatedUserId;
|
|
702
|
+
req.accessToken = token;
|
|
703
|
+
req.sessionId = sessionId;
|
|
704
|
+
// Session validation already returned the full user, so `loadUser`
|
|
705
|
+
// costs no extra round-trip.
|
|
706
|
+
req.user = loadUser ? validationResult.user : ({ id: validatedUserId } as User);
|
|
657
707
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const fullUser = await oxyInstance.getCurrentUser();
|
|
665
|
-
// Restore previous token
|
|
666
|
-
if (prevToken) {
|
|
667
|
-
oxyInstance.setTokens(prevToken);
|
|
668
|
-
} else {
|
|
669
|
-
oxyInstance.clearTokens();
|
|
670
|
-
}
|
|
708
|
+
if (debug) {
|
|
709
|
+
logger.debug(`[oxy.auth] OK user=${validatedUserId} session=${sessionId}`, {
|
|
710
|
+
component: 'auth',
|
|
711
|
+
method: 'auth',
|
|
712
|
+
});
|
|
713
|
+
}
|
|
671
714
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
// Loading the full user is best-effort here; the basic { id }
|
|
677
|
-
// object is already attached. Log so misconfigured deployments
|
|
678
|
-
// can be diagnosed instead of silently failing.
|
|
679
|
-
logger.warn('[oxy.auth] loadUser fallback — could not fetch full profile', {
|
|
715
|
+
return next();
|
|
716
|
+
} catch (validationError) {
|
|
717
|
+
if (debug) {
|
|
718
|
+
logger.debug('[oxy.auth] Session validation failed', {
|
|
680
719
|
component: 'auth',
|
|
681
|
-
method: 'auth
|
|
682
|
-
|
|
683
|
-
}, loadUserError);
|
|
720
|
+
method: 'auth',
|
|
721
|
+
}, validationError);
|
|
684
722
|
}
|
|
685
|
-
}
|
|
686
723
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
}
|
|
724
|
+
if (optional) {
|
|
725
|
+
req.userId = null;
|
|
726
|
+
req.user = null;
|
|
727
|
+
return next();
|
|
728
|
+
}
|
|
693
729
|
|
|
694
|
-
|
|
730
|
+
const error = {
|
|
731
|
+
error: 'SESSION_VALIDATION_ERROR',
|
|
732
|
+
message: 'Session validation failed',
|
|
733
|
+
code: 'SESSION_VALIDATION_ERROR',
|
|
734
|
+
status: 401
|
|
735
|
+
};
|
|
736
|
+
if (onError) return onError(error);
|
|
737
|
+
return res.status(401).json(error);
|
|
738
|
+
}
|
|
695
739
|
} catch (error) {
|
|
696
740
|
const handled = oxyInstance.handleError(error) as Error & {
|
|
697
741
|
code?: string;
|
|
@@ -768,7 +812,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
768
812
|
return next(new Error('Invalid token'));
|
|
769
813
|
}
|
|
770
814
|
|
|
771
|
-
const claimedUserId = decoded.userId
|
|
815
|
+
const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
|
|
772
816
|
if (!claimedUserId) {
|
|
773
817
|
return next(new Error('Invalid token payload'));
|
|
774
818
|
}
|
|
@@ -781,13 +825,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
781
825
|
// A server-validated session is mandatory. A bare decoded JWT proves
|
|
782
826
|
// nothing — the signature is not verified here, so without a session
|
|
783
827
|
// round-trip a forged token could claim any user id.
|
|
784
|
-
|
|
828
|
+
const sessionId = readStringClaim(decoded.sessionId);
|
|
829
|
+
if (!sessionId) {
|
|
785
830
|
return next(new Error('Session required'));
|
|
786
831
|
}
|
|
787
832
|
|
|
788
833
|
let userId = claimedUserId;
|
|
789
834
|
try {
|
|
790
|
-
const result = await oxyInstance.validateSession(
|
|
835
|
+
const result = await oxyInstance.validateSession(sessionId, {
|
|
791
836
|
useHeaderValidation: true,
|
|
792
837
|
});
|
|
793
838
|
if (!result || !result.valid || !result.user) {
|
|
@@ -819,10 +864,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
819
864
|
// reads from `socket.user.id`.
|
|
820
865
|
socket.data = socket.data || {};
|
|
821
866
|
socket.data.userId = userId;
|
|
822
|
-
socket.data.sessionId =
|
|
867
|
+
socket.data.sessionId = sessionId;
|
|
823
868
|
socket.data.token = token;
|
|
824
869
|
|
|
825
|
-
socket.user = { id: userId, userId, sessionId
|
|
870
|
+
socket.user = { id: userId, userId, sessionId };
|
|
826
871
|
|
|
827
872
|
if (debug) {
|
|
828
873
|
logger.debug(`[oxy.authSocket] OK user=${userId}`, {
|
|
@@ -978,12 +1023,17 @@ async function verifyServiceTokenSignature(token: string, secret: string): Promi
|
|
|
978
1023
|
}
|
|
979
1024
|
|
|
980
1025
|
/**
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
1026
|
+
* Read a JWT claim that is only usable as a non-empty string.
|
|
1027
|
+
*
|
|
1028
|
+
* A decoded payload is attacker-controlled JSON: a claim the type declares as
|
|
1029
|
+
* `string` can arrive as a number, an object, or `null`. Narrowing here keeps
|
|
1030
|
+
* those values out of URL construction and identity comparison, so an
|
|
1031
|
+
* unexpected shape becomes a 401 rather than a stringified surprise.
|
|
986
1032
|
*/
|
|
1033
|
+
function readStringClaim(value: unknown): string | null {
|
|
1034
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
987
1037
|
/**
|
|
988
1038
|
* Resolve the canonical user id from a validated session's user object.
|
|
989
1039
|
*
|
|
@@ -997,6 +1047,13 @@ function getUserIdentityId(user: User): string | null {
|
|
|
997
1047
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
|
|
998
1048
|
}
|
|
999
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* Verify that a decoded service-token payload carries the expected `aud`,
|
|
1052
|
+
* `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
|
|
1053
|
+
* This is the defence against the H4 vulnerability where a recovery / 2FA /
|
|
1054
|
+
* access token signed by the same shared secret could be replayed as a
|
|
1055
|
+
* service token because no claim binding existed.
|
|
1056
|
+
*/
|
|
1000
1057
|
function verifyServiceTokenClaims(
|
|
1001
1058
|
decoded: JwtPayload,
|
|
1002
1059
|
expected: { audience: string; issuer: string },
|
|
@@ -1062,9 +1119,5 @@ interface OxyAuthInstance {
|
|
|
1062
1119
|
user?: User;
|
|
1063
1120
|
[key: string]: unknown;
|
|
1064
1121
|
} | null>;
|
|
1065
|
-
getAccessToken(): string | null;
|
|
1066
|
-
setTokens(accessToken: string): void;
|
|
1067
|
-
clearTokens(): void;
|
|
1068
|
-
getCurrentUser(): Promise<User | null>;
|
|
1069
1122
|
handleError(error: unknown): Error;
|
|
1070
1123
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chains client — what it puts on the wire.
|
|
3
|
+
*
|
|
4
|
+
* There is exactly one piece of real logic here and it is the read's query
|
|
5
|
+
* string: joining two lists, and including `since`/`limit` only when the caller
|
|
6
|
+
* gave them. Everything else is a pass-through, so these cases pin the part that
|
|
7
|
+
* can actually be wrong rather than restating the method bodies.
|
|
8
|
+
*
|
|
9
|
+
* `makeServiceRequest` is stubbed because it belongs to the auth mixin and has
|
|
10
|
+
* its own suites; what matters here is the method, path and payload it is handed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { OxyServicesChainsMixin } from '../OxyServices.chains';
|
|
14
|
+
|
|
15
|
+
type Call = { method: string; url: string; data?: unknown };
|
|
16
|
+
|
|
17
|
+
/** A minimal host carrying the mixin, with the service transport recorded. */
|
|
18
|
+
function client(): { calls: Call[]; api: any } {
|
|
19
|
+
const calls: Call[] = [];
|
|
20
|
+
class Base {
|
|
21
|
+
makeServiceRequest(method: string, url: string, data?: unknown) {
|
|
22
|
+
calls.push({ method, url, data });
|
|
23
|
+
return Promise.resolve({ records: [], nextCursor: null });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const Mixed = OxyServicesChainsMixin(Base as any);
|
|
27
|
+
return { calls, api: new (Mixed as any)() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('appendChainRecord', () => {
|
|
31
|
+
it('POSTs the record to /chains/records untouched', async () => {
|
|
32
|
+
const { calls, api } = client();
|
|
33
|
+
|
|
34
|
+
await api.appendChainRecord({
|
|
35
|
+
oxyUserId: 'u1',
|
|
36
|
+
collection: 'app.mention.feed.post',
|
|
37
|
+
rkey: 'p1',
|
|
38
|
+
record: { text: 'hi' },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(calls).toEqual([
|
|
42
|
+
{
|
|
43
|
+
method: 'POST',
|
|
44
|
+
url: '/chains/records',
|
|
45
|
+
data: {
|
|
46
|
+
oxyUserId: 'u1',
|
|
47
|
+
collection: 'app.mention.feed.post',
|
|
48
|
+
rkey: 'p1',
|
|
49
|
+
record: { text: 'hi' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('readChainRecords', () => {
|
|
57
|
+
it('joins authors and collections into one comma-separated query each', async () => {
|
|
58
|
+
const { calls, api } = client();
|
|
59
|
+
|
|
60
|
+
await api.readChainRecords({
|
|
61
|
+
oxyUserIds: ['u1', 'u2'],
|
|
62
|
+
collections: ['app.mention.feed.post', 'app.mention.feed.like'],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const url = new URL(`http://x${calls[0].url}`);
|
|
66
|
+
expect(calls[0].method).toBe('GET');
|
|
67
|
+
expect(url.pathname).toBe('/chains/records');
|
|
68
|
+
expect(url.searchParams.get('authors')).toBe('u1,u2');
|
|
69
|
+
expect(url.searchParams.get('collections')).toBe('app.mention.feed.post,app.mention.feed.like');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('omits since and limit when the caller gave neither', async () => {
|
|
73
|
+
// A `since=` or `limit=` sent as an empty string is not the same request —
|
|
74
|
+
// the server validates both, so an always-present key would 400 a first page.
|
|
75
|
+
const { calls, api } = client();
|
|
76
|
+
|
|
77
|
+
await api.readChainRecords({ oxyUserIds: ['u1'], collections: ['app.mention.feed.post'] });
|
|
78
|
+
|
|
79
|
+
const url = new URL(`http://x${calls[0].url}`);
|
|
80
|
+
expect(url.searchParams.has('since')).toBe(false);
|
|
81
|
+
expect(url.searchParams.has('limit')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('sends since and limit when it did', async () => {
|
|
85
|
+
const { calls, api } = client();
|
|
86
|
+
|
|
87
|
+
await api.readChainRecords({
|
|
88
|
+
oxyUserIds: ['u1'],
|
|
89
|
+
collections: ['app.mention.feed.post'],
|
|
90
|
+
since: 'Y3Vyc29y',
|
|
91
|
+
limit: 25,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const url = new URL(`http://x${calls[0].url}`);
|
|
95
|
+
expect(url.searchParams.get('since')).toBe('Y3Vyc29y');
|
|
96
|
+
expect(url.searchParams.get('limit')).toBe('25');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('escapes a cursor that is not URL-safe', async () => {
|
|
100
|
+
// Cursors are opaque to the caller, so this client must not assume the
|
|
101
|
+
// encoding the server happens to use today.
|
|
102
|
+
const { calls, api } = client();
|
|
103
|
+
|
|
104
|
+
await api.readChainRecords({
|
|
105
|
+
oxyUserIds: ['u1'],
|
|
106
|
+
collections: ['app.mention.feed.post'],
|
|
107
|
+
since: 'a+b/c=',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const url = new URL(`http://x${calls[0].url}`);
|
|
111
|
+
expect(url.searchParams.get('since')).toBe('a+b/c=');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -158,7 +158,40 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
// A third-party grant is meant to be ISOLATED from the browser's shared
|
|
162
|
+
// DeviceSession, so the token endpoint must be free to return no device
|
|
163
|
+
// credential. Core used to require the pair, which made that omission
|
|
164
|
+
// unshippable: every third-party sign-in through the SDK would have collapsed
|
|
165
|
+
// into a silent `exchange-failed` (issue #954).
|
|
166
|
+
it('exchangeOAuthCode accepts a device-less grant and still plants the token', async () => {
|
|
167
|
+
makeRequest.mockResolvedValueOnce({
|
|
168
|
+
access_token: 'tok',
|
|
169
|
+
token_type: 'Bearer',
|
|
170
|
+
expires_in: 900,
|
|
171
|
+
session_id: 's1',
|
|
172
|
+
user: { id: 'u1', username: 'alice' },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const result = await oxy.exchangeOAuthCode({
|
|
176
|
+
code: 'code-1',
|
|
177
|
+
clientId: 'oxy_dk_test',
|
|
178
|
+
redirectUri: 'https://app.example/callback',
|
|
179
|
+
codeVerifier: 'verifier',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result).toMatchObject({
|
|
183
|
+
sessionId: 's1',
|
|
184
|
+
accessToken: 'tok',
|
|
185
|
+
user: { id: 'u1', username: 'alice' },
|
|
186
|
+
});
|
|
187
|
+
// Absent, not `undefined`-valued: a device-less grant carries no device keys
|
|
188
|
+
// at all, so nothing downstream can read one and persist an empty credential.
|
|
189
|
+
expect(result).not.toHaveProperty('deviceId');
|
|
190
|
+
expect(result).not.toHaveProperty('deviceSecret');
|
|
191
|
+
expect(oxy.getAccessToken()).toBe('tok');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('exchangeOAuthCode accepts a deviceId with no deviceSecret', async () => {
|
|
162
195
|
makeRequest.mockResolvedValueOnce({
|
|
163
196
|
access_token: 'tok',
|
|
164
197
|
token_type: 'Bearer',
|
|
@@ -167,6 +200,26 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
167
200
|
deviceId: 'd1',
|
|
168
201
|
user: { id: 'u1' },
|
|
169
202
|
});
|
|
203
|
+
|
|
204
|
+
const result = await oxy.exchangeOAuthCode({
|
|
205
|
+
code: 'code-1',
|
|
206
|
+
clientId: 'oxy_dk_test',
|
|
207
|
+
redirectUri: 'https://app.example/callback',
|
|
208
|
+
codeVerifier: 'verifier',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(result).toMatchObject({ sessionId: 's1', deviceId: 'd1' });
|
|
212
|
+
expect(result).not.toHaveProperty('deviceSecret');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// The device pair left the guard; what identifies the session did NOT. Without
|
|
216
|
+
// these two the whole guard could be deleted and every test above would stay
|
|
217
|
+
// green — `sessionId` would silently become `undefined` on the returned session.
|
|
218
|
+
it.each([
|
|
219
|
+
['session_id', { access_token: 'tok', expires_in: 900, user: { id: 'u1' } }],
|
|
220
|
+
['user', { access_token: 'tok', expires_in: 900, session_id: 's1' }],
|
|
221
|
+
])('exchangeOAuthCode still rejects a response missing %s', async (_field, response) => {
|
|
222
|
+
makeRequest.mockResolvedValueOnce(response);
|
|
170
223
|
await expect(
|
|
171
224
|
oxy.exchangeOAuthCode({
|
|
172
225
|
code: 'code-1',
|