@oxyhq/core 1.11.20 → 1.11.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.20",
3
+ "version": "1.11.22",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -353,7 +353,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
353
353
  deviceFingerprint?: string
354
354
  ): Promise<SessionLoginResponse> {
355
355
  try {
356
- return await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
356
+ const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
357
357
  publicKey,
358
358
  challenge,
359
359
  signature,
@@ -361,6 +361,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
361
361
  deviceName,
362
362
  deviceFingerprint,
363
363
  }, { cache: false });
364
+
365
+ // Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
366
+ // `/auth/verify` returns the first access token (and refresh token) in
367
+ // its body, so installing it here means callers get an authenticated
368
+ // client without a second round-trip — and, critically, without
369
+ // falling back to the bearer-protected `GET /session/token/:sessionId`
370
+ // (C1 hardening), which 401s for a brand-new identity that has no
371
+ // bearer yet. `accessToken`/`refreshToken` are optional on
372
+ // SessionLoginResponse; only plant when an access token is present and
373
+ // default the refresh token to an empty string.
374
+ if (res?.accessToken) {
375
+ this.setTokens(res.accessToken, res.refreshToken ?? '');
376
+ }
377
+
378
+ return res;
364
379
  } catch (error) {
365
380
  throw this.handleError(error);
366
381
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `verifyChallenge` token-planting regression tests.
3
+ *
4
+ * `OxyServices.verifyChallenge()` returns a `SessionLoginResponse` carrying the
5
+ * first `accessToken`/`refreshToken` minted by `POST /auth/verify`. It must
6
+ * PLANT those tokens internally — mirroring its sibling `claimSessionByToken` —
7
+ * so callers (e.g. @oxyhq/services' `useAuthOperations.performSignIn`) end up
8
+ * with an authenticated client WITHOUT falling back to the bearer-protected
9
+ * `GET /session/token/:sessionId`. That fallback 401s for a brand-new identity
10
+ * that has no bearer yet and previously broke the entire new-identity
11
+ * onboarding flow.
12
+ *
13
+ * These tests stub `makeRequest` so the planting logic is exercised end-to-end
14
+ * against a real OxyServices instance, with token state observed via the public
15
+ * `hasValidToken()` / `getAccessToken()` surface.
16
+ */
17
+
18
+ import { OxyServices } from '../../OxyServices';
19
+
20
+ interface VerifyResponse {
21
+ sessionId: string;
22
+ deviceId: string;
23
+ expiresAt: string;
24
+ accessToken?: string;
25
+ refreshToken?: string;
26
+ user: { id: string; username: string };
27
+ }
28
+
29
+ function makeOxy(): OxyServices {
30
+ return new OxyServices({ baseURL: 'https://api.oxy.so' });
31
+ }
32
+
33
+ describe('OxyServices.verifyChallenge token planting', () => {
34
+ afterEach(() => {
35
+ jest.restoreAllMocks();
36
+ });
37
+
38
+ it('plants the access + refresh token from the /auth/verify response body', async () => {
39
+ const oxy = makeOxy();
40
+ expect(oxy.hasValidToken()).toBe(false);
41
+
42
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
43
+ if (url === '/auth/verify') {
44
+ return {
45
+ sessionId: 'sess_1',
46
+ deviceId: 'dev_1',
47
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
48
+ accessToken: 'access_verify',
49
+ refreshToken: 'refresh_verify',
50
+ user: { id: 'user_1', username: 'tester' },
51
+ } as never;
52
+ }
53
+ throw new Error(`unexpected request to ${url}`);
54
+ });
55
+
56
+ const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 123, 'Device', 'fp');
57
+
58
+ // Response still carries the tokens for callers that want them.
59
+ expect(session.accessToken).toBe('access_verify');
60
+ // ...and they are now planted on the client so subsequent requests are
61
+ // authenticated without a second round-trip.
62
+ expect(oxy.hasValidToken()).toBe(true);
63
+ expect(oxy.getAccessToken()).toBe('access_verify');
64
+ });
65
+
66
+ it('defaults the refresh token to an empty string when the response omits it', async () => {
67
+ const oxy = makeOxy();
68
+
69
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
70
+ if (url === '/auth/verify') {
71
+ return {
72
+ sessionId: 'sess_2',
73
+ deviceId: 'dev_2',
74
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
75
+ accessToken: 'access_only',
76
+ user: { id: 'user_2', username: 'tester2' },
77
+ } as never;
78
+ }
79
+ throw new Error(`unexpected request to ${url}`);
80
+ });
81
+
82
+ const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 456);
83
+
84
+ expect(session.accessToken).toBe('access_only');
85
+ expect(oxy.hasValidToken()).toBe(true);
86
+ expect(oxy.getAccessToken()).toBe('access_only');
87
+ });
88
+
89
+ it('does NOT plant (and stays unauthenticated) when the response carries no access token', async () => {
90
+ const oxy = makeOxy();
91
+
92
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
93
+ if (url === '/auth/verify') {
94
+ // Token-less new identity (onboarding) — no access token in the body.
95
+ return {
96
+ sessionId: 'sess_3',
97
+ deviceId: 'dev_3',
98
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
99
+ user: { id: 'user_3', username: 'tester3' },
100
+ } as VerifyResponse as never;
101
+ }
102
+ throw new Error(`unexpected request to ${url}`);
103
+ });
104
+
105
+ const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 789);
106
+
107
+ expect(session.accessToken).toBeUndefined();
108
+ // No token to plant — the client stays unauthenticated. Crucially the
109
+ // method does NOT reach for the bearer-protected session-token endpoint.
110
+ expect(oxy.hasValidToken()).toBe(false);
111
+ });
112
+
113
+ it('matches claimSessionByToken: both plant tokens via the same path', async () => {
114
+ const oxy = makeOxy();
115
+
116
+ jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
117
+ if (url === '/auth/session/claim') {
118
+ return {
119
+ accessToken: 'access_claim',
120
+ refreshToken: 'refresh_claim',
121
+ sessionId: 'sess_claim',
122
+ deviceId: 'dev_claim',
123
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
124
+ user: { id: 'user_claim', username: 'claimed' },
125
+ } as never;
126
+ }
127
+ throw new Error(`unexpected request to ${url}`);
128
+ });
129
+
130
+ await oxy.claimSessionByToken('session-token-abc');
131
+
132
+ expect(oxy.hasValidToken()).toBe(true);
133
+ expect(oxy.getAccessToken()).toBe('access_claim');
134
+ });
135
+ });