@oxyhq/core 12.10.3 → 12.10.5

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.
@@ -93,7 +93,7 @@ export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
93
93
  export { isOxyRpOrigin } from './utils/webauthnOrigin';
94
94
  export { runColdBoot } from './utils/coldBoot';
95
95
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
96
- export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, } from './utils/oauthPkce';
96
+ export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, OXY_OAUTH_RETURN_PATH_STORAGE_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, persistOAuthReturnPath, consumeOAuthReturnPath, } from './utils/oauthPkce';
97
97
  export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
98
98
  export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
99
99
  export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
@@ -85,11 +85,43 @@ export declare const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = "oxy_oauth_code_verif
85
85
  export declare const OXY_SILENT_OAUTH_ATTEMPTED_KEY = "oxy.silent_oauth_attempted";
86
86
  /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
87
87
  export declare const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = "oxy.cross_origin_restore_attempted";
88
+ /**
89
+ * `sessionStorage` key for the in-app path to return to after an authorize
90
+ * round trip. See {@link persistOAuthReturnPath}.
91
+ */
92
+ export declare const OXY_OAUTH_RETURN_PATH_STORAGE_KEY = "oxy.oauth_return_path";
88
93
  /**
89
94
  * Normalize a redirect URI to its origin. Official Oxy apps register apex
90
95
  * origins (`https://inbox.oxy.so`) — never path-qualified URLs.
96
+ *
97
+ * This is why the path cannot ride along in `redirect_uri`, and why
98
+ * {@link persistOAuthReturnPath} exists: the authorize round trip always lands
99
+ * back on the bare origin, so the page the user was on has to be carried
100
+ * out-of-band and restored on return.
91
101
  */
92
102
  export declare function normalizeOAuthRedirectUri(input: string): string;
103
+ /**
104
+ * Remember the page the user was on before a full-page authorize redirect.
105
+ *
106
+ * Silent cross-origin restore navigates the whole tab to the IdP and back, and
107
+ * `redirect_uri` must be a registered apex origin, so the browser returns to
108
+ * `/` no matter which page the visit started on. Without this, every deep link
109
+ * — a shared URL, a search result, an ad landing page — silently became the
110
+ * home page for signed-out visitors on their first navigation in a tab.
111
+ *
112
+ * Stored next to the PKCE handshake it belongs to, and cleared by
113
+ * {@link clearOAuthHandshake} so a stale entry can never be applied to an
114
+ * unrelated later navigation.
115
+ */
116
+ export declare function persistOAuthReturnPath(path: string): void;
117
+ /**
118
+ * Read and remove the persisted return path, or `null` when there is none or
119
+ * it failed validation.
120
+ *
121
+ * Single-use by design: it is consumed on the first return so a leftover value
122
+ * cannot redirect a later navigation in the same tab.
123
+ */
124
+ export declare function consumeOAuthReturnPath(): string | null;
93
125
  /** Persist the OAuth handshake for a full-page redirect return (web only). */
94
126
  export declare function persistOAuthHandshake(state: string, codeVerifier: string): boolean;
95
127
  /** Read the persisted OAuth handshake, or `null` when absent. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.10.3",
3
+ "version": "12.10.5",
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",
@@ -1552,6 +1552,7 @@
1552
1552
  "fileEmpty": "File \"{{name}}\" is empty",
1553
1553
  "fileTooLarge": "\"{{name}}\" is too large. Maximum file size is {{maxSize}}",
1554
1554
  "noValidFiles": "No valid files to upload",
1555
+ "imagesOnlyRequired": "Only image files can be selected",
1555
1556
  "waitForSelection": "Please wait for the current file selection to complete",
1556
1557
  "noFilesSelected": "No files were selected",
1557
1558
  "loadSomeFailed": "Failed to load some files:\n{{errors}}",
@@ -1761,7 +1762,9 @@
1761
1762
  "toasts": {
1762
1763
  "success": "Account created",
1763
1764
  "failed": "Failed to create account"
1764
- }
1765
+ },
1766
+ "typeSection": "Account type",
1767
+ "detailsSection": "Details"
1765
1768
  },
1766
1769
  "manage": {
1767
1770
  "create": {
@@ -2071,4 +2074,4 @@
2071
2074
  "deny": "Cancel",
2072
2075
  "disclaimer": "By continuing, {{app}} will be able to sign in with your Oxy account. You can manage connected apps anytime in your Oxy account settings."
2073
2076
  }
2074
- }
2077
+ }
@@ -1552,6 +1552,7 @@
1552
1552
  "fileEmpty": "El archivo \"{{name}}\" está vacío",
1553
1553
  "fileTooLarge": "\"{{name}}\" es demasiado grande. El tamaño máximo es {{maxSize}}",
1554
1554
  "noValidFiles": "No hay archivos válidos para subir",
1555
+ "imagesOnlyRequired": "Solo se pueden seleccionar archivos de imagen",
1555
1556
  "waitForSelection": "Espera a que se complete la selección actual",
1556
1557
  "noFilesSelected": "No se seleccionaron archivos",
1557
1558
  "loadSomeFailed": "Error al cargar algunos archivos:\n{{errors}}",
@@ -1761,7 +1762,9 @@
1761
1762
  "toasts": {
1762
1763
  "success": "Cuenta creada",
1763
1764
  "failed": "No se pudo crear la cuenta"
1764
- }
1765
+ },
1766
+ "typeSection": "Tipo de cuenta",
1767
+ "detailsSection": "Detalles"
1765
1768
  },
1766
1769
  "manage": {
1767
1770
  "create": {
@@ -2071,4 +2074,4 @@
2071
2074
  "deny": "Cancelar",
2072
2075
  "disclaimer": "Al continuar, {{app}} podrá iniciar sesión con tu cuenta de Oxy. Puedes gestionar las apps conectadas cuando quieras en los ajustes de tu cuenta de Oxy."
2073
2076
  }
2074
- }
2077
+ }
package/src/index.ts CHANGED
@@ -583,10 +583,13 @@ export {
583
583
  OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY,
584
584
  OXY_SILENT_OAUTH_ATTEMPTED_KEY,
585
585
  OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY,
586
+ OXY_OAUTH_RETURN_PATH_STORAGE_KEY,
586
587
  normalizeOAuthRedirectUri,
587
588
  persistOAuthHandshake,
588
589
  readOAuthHandshake,
589
590
  clearOAuthHandshake,
591
+ persistOAuthReturnPath,
592
+ consumeOAuthReturnPath,
590
593
  } from './utils/oauthPkce';
591
594
  export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
592
595
 
@@ -6,6 +6,10 @@ import {
6
6
  generateOAuthState,
7
7
  generatePkcePair,
8
8
  OXY_AUTHORIZE_URL,
9
+ OXY_OAUTH_RETURN_PATH_STORAGE_KEY,
10
+ clearOAuthHandshake,
11
+ consumeOAuthReturnPath,
12
+ persistOAuthReturnPath,
9
13
  } from '../oauthPkce';
10
14
 
11
15
  /** RFC 7636 unreserved subset produced by base64url (no `+`, `/`, `=`). */
@@ -152,3 +156,66 @@ describe('buildOAuthAuthorizeUrl', () => {
152
156
  expect(new URL(url).searchParams.get('redirect_uri')).toBe(redirectUri);
153
157
  });
154
158
  });
159
+
160
+ describe('OAuth return path', () => {
161
+ /** Minimal sessionStorage stand-in — jsdom is not assumed by this suite. */
162
+ function installSessionStorage(): Map<string, string> {
163
+ const map = new Map<string, string>();
164
+ (globalThis as { sessionStorage?: Storage }).sessionStorage = {
165
+ getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
166
+ setItem: (k: string, v: string) => void map.set(k, v),
167
+ removeItem: (k: string) => void map.delete(k),
168
+ clear: () => map.clear(),
169
+ key: (i: number) => [...map.keys()][i] ?? null,
170
+ get length() {
171
+ return map.size;
172
+ },
173
+ } as unknown as Storage;
174
+ return map;
175
+ }
176
+
177
+ afterEach(() => {
178
+ delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
179
+ });
180
+
181
+ it('round-trips a deep path through persist/consume', () => {
182
+ installSessionStorage();
183
+ persistOAuthReturnPath('/company/team?tab=eng#lead');
184
+ expect(consumeOAuthReturnPath()).toBe('/company/team?tab=eng#lead');
185
+ });
186
+
187
+ it('is single-use, so a stale value cannot redirect a later navigation', () => {
188
+ installSessionStorage();
189
+ persistOAuthReturnPath('/pricing');
190
+ expect(consumeOAuthReturnPath()).toBe('/pricing');
191
+ expect(consumeOAuthReturnPath()).toBeNull();
192
+ });
193
+
194
+ it('rejects protocol-relative and absolute values (open-redirect guard)', () => {
195
+ const store = installSessionStorage();
196
+ for (const hostile of ['//evil.com', '/\\evil.com', 'https://evil.com/x', 'evil.com']) {
197
+ persistOAuthReturnPath(hostile);
198
+ expect(store.get(OXY_OAUTH_RETURN_PATH_STORAGE_KEY)).toBeUndefined();
199
+ }
200
+ });
201
+
202
+ it('rejects a hostile value written directly into storage', () => {
203
+ const store = installSessionStorage();
204
+ store.set(OXY_OAUTH_RETURN_PATH_STORAGE_KEY, '//evil.com');
205
+ expect(consumeOAuthReturnPath()).toBeNull();
206
+ // Consumed even when rejected, so it cannot be retried.
207
+ expect(store.get(OXY_OAUTH_RETURN_PATH_STORAGE_KEY)).toBeUndefined();
208
+ });
209
+
210
+ it('is cleared with the handshake it belongs to', () => {
211
+ const store = installSessionStorage();
212
+ persistOAuthReturnPath('/newsroom');
213
+ clearOAuthHandshake();
214
+ expect(store.get(OXY_OAUTH_RETURN_PATH_STORAGE_KEY)).toBeUndefined();
215
+ });
216
+
217
+ it('no-ops without sessionStorage instead of throwing', () => {
218
+ expect(() => persistOAuthReturnPath('/pricing')).not.toThrow();
219
+ expect(consumeOAuthReturnPath()).toBeNull();
220
+ });
221
+ });
@@ -208,9 +208,20 @@ export const OXY_SILENT_OAUTH_ATTEMPTED_KEY = 'oxy.silent_oauth_attempted';
208
208
  /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
209
209
  export const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = 'oxy.cross_origin_restore_attempted';
210
210
 
211
+ /**
212
+ * `sessionStorage` key for the in-app path to return to after an authorize
213
+ * round trip. See {@link persistOAuthReturnPath}.
214
+ */
215
+ export const OXY_OAUTH_RETURN_PATH_STORAGE_KEY = 'oxy.oauth_return_path';
216
+
211
217
  /**
212
218
  * Normalize a redirect URI to its origin. Official Oxy apps register apex
213
219
  * origins (`https://inbox.oxy.so`) — never path-qualified URLs.
220
+ *
221
+ * This is why the path cannot ride along in `redirect_uri`, and why
222
+ * {@link persistOAuthReturnPath} exists: the authorize round trip always lands
223
+ * back on the bare origin, so the page the user was on has to be carried
224
+ * out-of-band and restored on return.
214
225
  */
215
226
  export function normalizeOAuthRedirectUri(input: string): string {
216
227
  try {
@@ -220,6 +231,69 @@ export function normalizeOAuthRedirectUri(input: string): string {
220
231
  }
221
232
  }
222
233
 
234
+ /**
235
+ * Is this a safe same-origin path to restore into the address bar?
236
+ *
237
+ * Only site-relative paths are accepted. `//evil.com` and `/\evil.com` are
238
+ * rejected because browsers resolve both as protocol-relative *absolute* URLs —
239
+ * without this check, anything able to write one key of `sessionStorage` could
240
+ * turn the post-authorize restore into an open redirect.
241
+ */
242
+ function isSafeReturnPath(value: string): boolean {
243
+ if (!value.startsWith('/')) return false;
244
+ if (value.startsWith('//') || value.startsWith('/\\')) return false;
245
+ if (value.length > 2048) return false;
246
+ return true;
247
+ }
248
+
249
+ /**
250
+ * Remember the page the user was on before a full-page authorize redirect.
251
+ *
252
+ * Silent cross-origin restore navigates the whole tab to the IdP and back, and
253
+ * `redirect_uri` must be a registered apex origin, so the browser returns to
254
+ * `/` no matter which page the visit started on. Without this, every deep link
255
+ * — a shared URL, a search result, an ad landing page — silently became the
256
+ * home page for signed-out visitors on their first navigation in a tab.
257
+ *
258
+ * Stored next to the PKCE handshake it belongs to, and cleared by
259
+ * {@link clearOAuthHandshake} so a stale entry can never be applied to an
260
+ * unrelated later navigation.
261
+ */
262
+ export function persistOAuthReturnPath(path: string): void {
263
+ const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
264
+ if (!store || !isSafeReturnPath(path)) return;
265
+ try {
266
+ store.setItem(OXY_OAUTH_RETURN_PATH_STORAGE_KEY, path);
267
+ } catch (error) {
268
+ // Non-fatal: the round trip still completes, it just lands on the origin.
269
+ logger.warn(
270
+ 'Could not persist OAuth return path to sessionStorage',
271
+ { component: 'oauthPkce' },
272
+ error,
273
+ );
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read and remove the persisted return path, or `null` when there is none or
279
+ * it failed validation.
280
+ *
281
+ * Single-use by design: it is consumed on the first return so a leftover value
282
+ * cannot redirect a later navigation in the same tab.
283
+ */
284
+ export function consumeOAuthReturnPath(): string | null {
285
+ const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
286
+ if (!store) return null;
287
+ try {
288
+ const value = store.getItem(OXY_OAUTH_RETURN_PATH_STORAGE_KEY);
289
+ store.removeItem(OXY_OAUTH_RETURN_PATH_STORAGE_KEY);
290
+ if (!value || !isSafeReturnPath(value)) return null;
291
+ return value;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
223
297
  /** Persist the OAuth handshake for a full-page redirect return (web only). */
224
298
  export function persistOAuthHandshake(state: string, codeVerifier: string): boolean {
225
299
  const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
@@ -254,6 +328,9 @@ export function clearOAuthHandshake(): void {
254
328
  try {
255
329
  store?.removeItem(OXY_OAUTH_STATE_STORAGE_KEY);
256
330
  store?.removeItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
331
+ // The return path belongs to this handshake. Leaving it behind would let a
332
+ // later, unrelated navigation in the same tab be redirected by it.
333
+ store?.removeItem(OXY_OAUTH_RETURN_PATH_STORAGE_KEY);
257
334
  } catch {
258
335
  // Best-effort cleanup only.
259
336
  }