@quiltt/react-native 3.9.5 → 3.9.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @quiltt/react-native
2
2
 
3
+ ## 3.9.7
4
+
5
+ ### Patch Changes
6
+
7
+ - [#330](https://github.com/quiltt/quiltt-js/pull/330) [`e7b8e74`](https://github.com/quiltt/quiltt-js/commit/e7b8e74613f7725c6f2653be6d8ac0e06cce661d) Thanks [@zubairaziz](https://github.com/zubairaziz)! - Make OAuth Handling Safer
8
+
9
+ - Updated dependencies [[`e7b8e74`](https://github.com/quiltt/quiltt-js/commit/e7b8e74613f7725c6f2653be6d8ac0e06cce661d)]:
10
+ - @quiltt/core@3.9.7
11
+ - @quiltt/react@3.9.7
12
+
13
+ ## 3.9.6
14
+
15
+ ### Patch Changes
16
+
17
+ - [#328](https://github.com/quiltt/quiltt-js/pull/328) [`6b8751c`](https://github.com/quiltt/quiltt-js/commit/6b8751c981e9e74b347227bc9f427585d21870cd) Thanks [@zubairaziz](https://github.com/zubairaziz)! - Updated QuilttConnector OAuth handler
18
+
19
+ - Updated dependencies [[`6b8751c`](https://github.com/quiltt/quiltt-js/commit/6b8751c981e9e74b347227bc9f427585d21870cd)]:
20
+ - @quiltt/react@3.9.6
21
+ - @quiltt/core@3.9.6
22
+
3
23
  ## 3.9.5
4
24
 
5
25
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -9,7 +9,10 @@ type PreFlightCheck = {
9
9
  error?: string;
10
10
  };
11
11
  declare const checkConnectorUrl: (connectorUrl: string, retryCount?: number) => Promise<PreFlightCheck>;
12
- declare const handleOAuthUrl: (oauthUrl: URL | string) => void;
12
+ /**
13
+ * Handle opening OAuth URLs with proper encoding detection and normalization
14
+ */
15
+ declare const handleOAuthUrl: (oauthUrl: URL | string | null | undefined) => void;
13
16
  type QuilttConnectorProps = {
14
17
  testId?: string;
15
18
  connectorId: string;
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { URL } from 'react-native-url-polyfill';
9
9
  import { WebView } from 'react-native-webview';
10
10
  import { generateStackTrace, makeBacktrace, getCauses } from '@honeybadger-io/core/build/src/util';
11
11
 
12
- var version = "3.9.5";
12
+ var version = "3.9.7";
13
13
 
14
14
  const ErrorReporterConfig = {
15
15
  honeybadger_api_key: 'undefined'
@@ -99,6 +99,52 @@ const getErrorMessage = (responseStatus, error)=>{
99
99
  return responseStatus ? `The URL is not routable. Response status: ${responseStatus}` : 'An error occurred while checking the connector URL';
100
100
  };
101
101
 
102
+ /**
103
+ * Checks if a string appears to be already URL encoded
104
+ * @param str The string to check
105
+ * @returns boolean indicating if the string appears to be URL encoded
106
+ */ const isEncoded = (str)=>{
107
+ // Check for typical URL encoding patterns like %20, %3A, etc.
108
+ const hasEncodedChars = /%[0-9A-F]{2}/i.test(str);
109
+ // Check if double encoding has occurred (e.g., %253A instead of %3A)
110
+ const hasDoubleEncoding = /%25[0-9A-F]{2}/i.test(str);
111
+ // If we have encoded chars but no double encoding, it's likely properly encoded
112
+ return hasEncodedChars && !hasDoubleEncoding;
113
+ };
114
+ /**
115
+ * Smart URL encoder that ensures a string is encoded exactly once
116
+ * @param str The string to encode
117
+ * @returns A properly URL encoded string
118
+ */ const smartEncodeURIComponent = (str)=>{
119
+ if (!str) return str;
120
+ // If it's already encoded, return as is
121
+ if (isEncoded(str)) {
122
+ console.log('URL already encoded, skipping encoding:', str);
123
+ return str;
124
+ }
125
+ // Otherwise, encode it
126
+ const encoded = encodeURIComponent(str);
127
+ console.log('URL encoded from:', str, 'to:', encoded);
128
+ return encoded;
129
+ };
130
+ /**
131
+ * Checks if a string appears to be double-encoded
132
+ */ const isDoubleEncoded = (str)=>{
133
+ if (!str) return false;
134
+ return /%25[0-9A-F]{2}/i.test(str);
135
+ };
136
+ /**
137
+ * Normalizes a URL string by decoding it once if it appears to be double-encoded
138
+ */ const normalizeUrlEncoding = (urlStr)=>{
139
+ if (isDoubleEncoded(urlStr)) {
140
+ console.log('Detected double-encoded URL:', urlStr);
141
+ const normalized = decodeURIComponent(urlStr);
142
+ console.log('Normalized to:', normalized);
143
+ return normalized;
144
+ }
145
+ return urlStr;
146
+ };
147
+
102
148
  const AndroidSafeAreaView = ({ testId, children })=>/*#__PURE__*/ jsx(SafeAreaView, {
103
149
  testID: testId,
104
150
  style: styles$2.AndroidSafeArea,
@@ -221,19 +267,18 @@ const checkConnectorUrl = async (connectorUrl, retryCount = 0)=>{
221
267
  const response = await fetch(connectorUrl);
222
268
  if (!response.ok) {
223
269
  responseStatus = response.status;
224
- throw new Error(`The URL ${connectorUrl} is not routable.`);
270
+ throw new Error('Connector URL is not routable.');
225
271
  }
226
- console.log(`The URL ${connectorUrl} is routable.`);
227
272
  return {
228
273
  checked: true
229
274
  };
230
275
  } catch (e) {
231
276
  error = e;
232
- console.error(`An error occurred while checking the connector URL: ${error}`);
277
+ console.error('Failed to connect to connector URL');
233
278
  if (retryCount < PREFLIGHT_RETRY_COUNT) {
234
279
  const delay = 50 * 2 ** retryCount;
235
280
  await new Promise((resolve)=>setTimeout(resolve, delay));
236
- console.log(`Retrying... Attempt number ${retryCount + 1}`);
281
+ console.log(`Retrying connection... Attempt ${retryCount + 1}`);
237
282
  return checkConnectorUrl(connectorUrl, retryCount + 1);
238
283
  }
239
284
  const errorMessage = getErrorMessage(responseStatus, error);
@@ -249,9 +294,37 @@ const checkConnectorUrl = async (connectorUrl, retryCount = 0)=>{
249
294
  };
250
295
  }
251
296
  };
252
- const handleOAuthUrl = (oauthUrl)=>{
253
- console.log(`handleOAuthUrl - Opening URL - ${oauthUrl.toString()}`);
254
- Linking.openURL(oauthUrl.toString());
297
+ /**
298
+ * Handle opening OAuth URLs with proper encoding detection and normalization
299
+ */ const handleOAuthUrl = (oauthUrl)=>{
300
+ try {
301
+ // Throw error if oauthUrl is null or undefined
302
+ if (oauthUrl == null) {
303
+ throw new Error('OAuth URL missing');
304
+ }
305
+ // Convert to string if it's a URL object
306
+ const urlString = oauthUrl.toString();
307
+ // Throw error if the resulting string is empty
308
+ if (!urlString || urlString.trim() === '') {
309
+ throw new Error('Empty OAuth URL');
310
+ }
311
+ // Normalize the URL encoding
312
+ const normalizedUrl = normalizeUrlEncoding(urlString);
313
+ // Open the normalized URL
314
+ Linking.openURL(normalizedUrl);
315
+ } catch (error) {
316
+ console.error('OAuth URL handling error');
317
+ // Only try the fallback if oauthUrl is not null
318
+ if (oauthUrl != null) {
319
+ try {
320
+ const fallbackUrl = typeof oauthUrl === 'string' ? oauthUrl : oauthUrl.toString();
321
+ console.log('Attempting fallback OAuth opening');
322
+ Linking.openURL(fallbackUrl);
323
+ } catch (fallbackError) {
324
+ console.error('Fallback OAuth opening failed');
325
+ }
326
+ }
327
+ }
255
328
  };
256
329
  const QuilttConnector = ({ testId, connectorId, connectionId, institution, oauthRedirectUrl, onEvent, onLoad, onExit, onExitSuccess, onExitAbort, onExitError })=>{
257
330
  const webViewRef = useRef(null);
@@ -277,18 +350,30 @@ const QuilttConnector = ({ testId, connectorId, connectionId, institution, oauth
277
350
  webViewRef.current?.injectJavaScript(disableHeaderScrollScript);
278
351
  }
279
352
  }, []);
280
- const encodedOAuthRedirectUrl = useMemo(()=>encodeURIComponent(oauthRedirectUrl), [
353
+ // Ensure oauthRedirectUrl is encoded properly - only once
354
+ const safeOAuthRedirectUrl = useMemo(()=>{
355
+ return smartEncodeURIComponent(oauthRedirectUrl);
356
+ }, [
281
357
  oauthRedirectUrl
282
358
  ]);
283
359
  const connectorUrl = useMemo(()=>{
284
360
  const url = new URL(`https://${connectorId}.quiltt.app`);
361
+ // For normal parameters, just append them directly
285
362
  url.searchParams.append('mode', 'webview');
286
- url.searchParams.append('oauth_redirect_url', encodedOAuthRedirectUrl);
287
363
  url.searchParams.append('agent', `react-native-${version}`);
364
+ // For the oauth_redirect_url, we need to be careful
365
+ // If it's already encoded, we need to decode it once to prevent
366
+ // the automatic encoding that happens with searchParams.append
367
+ if (isEncoded(safeOAuthRedirectUrl)) {
368
+ const decodedOnce = decodeURIComponent(safeOAuthRedirectUrl);
369
+ url.searchParams.append('oauth_redirect_url', decodedOnce);
370
+ } else {
371
+ url.searchParams.append('oauth_redirect_url', safeOAuthRedirectUrl);
372
+ }
288
373
  return url.toString();
289
374
  }, [
290
375
  connectorId,
291
- encodedOAuthRedirectUrl
376
+ safeOAuthRedirectUrl
292
377
  ]);
293
378
  useEffect(()=>{
294
379
  if (preFlightCheck.checked) return;
@@ -342,35 +427,58 @@ const QuilttConnector = ({ testId, connectorId, connectionId, institution, oauth
342
427
  const eventType = url.host;
343
428
  switch(eventType){
344
429
  case 'Load':
430
+ console.log('Event: Load');
345
431
  initInjectedJavaScript();
346
432
  onEvent?.(ConnectorSDKEventType.Load, metadata);
347
433
  onLoad?.(metadata);
348
434
  break;
349
435
  case 'ExitAbort':
436
+ console.log('Event: ExitAbort');
350
437
  clearLocalStorage();
351
438
  onEvent?.(ConnectorSDKEventType.ExitAbort, metadata);
352
439
  onExit?.(ConnectorSDKEventType.ExitAbort, metadata);
353
440
  onExitAbort?.(metadata);
354
441
  break;
355
442
  case 'ExitError':
443
+ console.log('Event: ExitError');
356
444
  clearLocalStorage();
357
445
  onEvent?.(ConnectorSDKEventType.ExitError, metadata);
358
446
  onExit?.(ConnectorSDKEventType.ExitError, metadata);
359
447
  onExitError?.(metadata);
360
448
  break;
361
449
  case 'ExitSuccess':
450
+ console.log('Event: ExitSuccess');
362
451
  clearLocalStorage();
363
452
  onEvent?.(ConnectorSDKEventType.ExitSuccess, metadata);
364
453
  onExit?.(ConnectorSDKEventType.ExitSuccess, metadata);
365
454
  onExitSuccess?.(metadata);
366
455
  break;
367
456
  case 'Authenticate':
457
+ console.log('Event: Authenticate');
368
458
  break;
369
459
  case 'OauthRequested':
370
- handleOAuthUrl(new URL(url.searchParams.get('oauthUrl')));
371
- break;
460
+ {
461
+ console.log('Event: OauthRequested');
462
+ const oauthUrl = url.searchParams.get('oauthUrl');
463
+ if (oauthUrl) {
464
+ if (isEncoded(oauthUrl)) {
465
+ try {
466
+ const decodedUrl = decodeURIComponent(oauthUrl);
467
+ handleOAuthUrl(decodedUrl);
468
+ } catch (error) {
469
+ console.error('OAuth URL decoding failed, using original');
470
+ handleOAuthUrl(oauthUrl);
471
+ }
472
+ } else {
473
+ handleOAuthUrl(oauthUrl);
474
+ }
475
+ } else {
476
+ console.error('OAuth URL missing from request');
477
+ }
478
+ break;
479
+ }
372
480
  default:
373
- console.log('unhandled event', url);
481
+ console.log(`Unhandled event: ${eventType}`);
374
482
  break;
375
483
  }
376
484
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quiltt/react-native",
3
- "version": "3.9.5",
3
+ "version": "3.9.7",
4
4
  "description": "React Native Components for Quiltt Connector",
5
5
  "homepage": "https://github.com/quiltt/quiltt-js/tree/main/packages/react-native#readme",
6
6
  "repository": {
@@ -30,14 +30,14 @@
30
30
  "dependencies": {
31
31
  "@honeybadger-io/core": "6.6.0",
32
32
  "lodash.debounce": "4.0.8",
33
- "@quiltt/react": "3.9.5",
34
- "@quiltt/core": "3.9.5"
33
+ "@quiltt/core": "3.9.7",
34
+ "@quiltt/react": "3.9.7"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@biomejs/biome": "1.9.4",
38
38
  "@types/base-64": "1.0.2",
39
39
  "@types/lodash.debounce": "4.0.9",
40
- "@types/node": "22.13.2",
40
+ "@types/node": "22.13.10",
41
41
  "@types/react": "18.3.12",
42
42
  "base-64": "1.0.0",
43
43
  "bunchee": "6.3.4",
@@ -8,7 +8,13 @@ import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTyp
8
8
  import { ConnectorSDKEventType, useQuilttSession } from '@quiltt/react'
9
9
  import type { ConnectorSDKCallbackMetadata, ConnectorSDKCallbacks } from '@quiltt/react'
10
10
 
11
- import { ErrorReporter, getErrorMessage } from '@/utils'
11
+ import {
12
+ ErrorReporter,
13
+ getErrorMessage,
14
+ isEncoded,
15
+ normalizeUrlEncoding,
16
+ smartEncodeURIComponent,
17
+ } from '@/utils'
12
18
  import { version } from '@/version'
13
19
  import { AndroidSafeAreaView } from './AndroidSafeAreaView'
14
20
  import { ErrorScreen } from './ErrorScreen'
@@ -32,18 +38,17 @@ export const checkConnectorUrl = async (
32
38
  const response = await fetch(connectorUrl)
33
39
  if (!response.ok) {
34
40
  responseStatus = response.status
35
- throw new Error(`The URL ${connectorUrl} is not routable.`)
41
+ throw new Error('Connector URL is not routable.')
36
42
  }
37
- console.log(`The URL ${connectorUrl} is routable.`)
38
43
  return { checked: true }
39
44
  } catch (e) {
40
45
  error = e as Error
41
- console.error(`An error occurred while checking the connector URL: ${error}`)
46
+ console.error('Failed to connect to connector URL')
42
47
 
43
48
  if (retryCount < PREFLIGHT_RETRY_COUNT) {
44
49
  const delay = 50 * 2 ** retryCount
45
50
  await new Promise((resolve) => setTimeout(resolve, delay))
46
- console.log(`Retrying... Attempt number ${retryCount + 1}`)
51
+ console.log(`Retrying connection... Attempt ${retryCount + 1}`)
47
52
  return checkConnectorUrl(connectorUrl, retryCount + 1)
48
53
  }
49
54
  const errorMessage = getErrorMessage(responseStatus, error as Error)
@@ -54,9 +59,43 @@ export const checkConnectorUrl = async (
54
59
  }
55
60
  }
56
61
 
57
- export const handleOAuthUrl = (oauthUrl: URL | string) => {
58
- console.log(`handleOAuthUrl - Opening URL - ${oauthUrl.toString()}`)
59
- Linking.openURL(oauthUrl.toString())
62
+ /**
63
+ * Handle opening OAuth URLs with proper encoding detection and normalization
64
+ */
65
+ export const handleOAuthUrl = (oauthUrl: URL | string | null | undefined) => {
66
+ try {
67
+ // Throw error if oauthUrl is null or undefined
68
+ if (oauthUrl == null) {
69
+ throw new Error('OAuth URL missing')
70
+ }
71
+
72
+ // Convert to string if it's a URL object
73
+ const urlString = oauthUrl.toString()
74
+
75
+ // Throw error if the resulting string is empty
76
+ if (!urlString || urlString.trim() === '') {
77
+ throw new Error('Empty OAuth URL')
78
+ }
79
+
80
+ // Normalize the URL encoding
81
+ const normalizedUrl = normalizeUrlEncoding(urlString)
82
+
83
+ // Open the normalized URL
84
+ Linking.openURL(normalizedUrl)
85
+ } catch (error) {
86
+ console.error('OAuth URL handling error')
87
+
88
+ // Only try the fallback if oauthUrl is not null
89
+ if (oauthUrl != null) {
90
+ try {
91
+ const fallbackUrl = typeof oauthUrl === 'string' ? oauthUrl : oauthUrl.toString()
92
+ console.log('Attempting fallback OAuth opening')
93
+ Linking.openURL(fallbackUrl)
94
+ } catch (fallbackError) {
95
+ console.error('Fallback OAuth opening failed')
96
+ }
97
+ }
98
+ }
60
99
  }
61
100
 
62
101
  type QuilttConnectorProps = {
@@ -104,18 +143,30 @@ const QuilttConnector = ({
104
143
  }
105
144
  }, [])
106
145
 
107
- const encodedOAuthRedirectUrl = useMemo(
108
- () => encodeURIComponent(oauthRedirectUrl),
109
- [oauthRedirectUrl]
110
- )
146
+ // Ensure oauthRedirectUrl is encoded properly - only once
147
+ const safeOAuthRedirectUrl = useMemo(() => {
148
+ return smartEncodeURIComponent(oauthRedirectUrl)
149
+ }, [oauthRedirectUrl])
111
150
 
112
151
  const connectorUrl = useMemo(() => {
113
152
  const url = new URL(`https://${connectorId}.quiltt.app`)
153
+
154
+ // For normal parameters, just append them directly
114
155
  url.searchParams.append('mode', 'webview')
115
- url.searchParams.append('oauth_redirect_url', encodedOAuthRedirectUrl)
116
156
  url.searchParams.append('agent', `react-native-${version}`)
157
+
158
+ // For the oauth_redirect_url, we need to be careful
159
+ // If it's already encoded, we need to decode it once to prevent
160
+ // the automatic encoding that happens with searchParams.append
161
+ if (isEncoded(safeOAuthRedirectUrl)) {
162
+ const decodedOnce = decodeURIComponent(safeOAuthRedirectUrl)
163
+ url.searchParams.append('oauth_redirect_url', decodedOnce)
164
+ } else {
165
+ url.searchParams.append('oauth_redirect_url', safeOAuthRedirectUrl)
166
+ }
167
+
117
168
  return url.toString()
118
- }, [connectorId, encodedOAuthRedirectUrl])
169
+ }, [connectorId, safeOAuthRedirectUrl])
119
170
 
120
171
  useEffect(() => {
121
172
  if (preFlightCheck.checked) return
@@ -166,36 +217,59 @@ const QuilttConnector = ({
166
217
  const eventType = url.host
167
218
  switch (eventType) {
168
219
  case 'Load':
220
+ console.log('Event: Load')
169
221
  initInjectedJavaScript()
170
222
  onEvent?.(ConnectorSDKEventType.Load, metadata)
171
223
  onLoad?.(metadata)
172
224
  break
173
225
  case 'ExitAbort':
226
+ console.log('Event: ExitAbort')
174
227
  clearLocalStorage()
175
228
  onEvent?.(ConnectorSDKEventType.ExitAbort, metadata)
176
229
  onExit?.(ConnectorSDKEventType.ExitAbort, metadata)
177
230
  onExitAbort?.(metadata)
178
231
  break
179
232
  case 'ExitError':
233
+ console.log('Event: ExitError')
180
234
  clearLocalStorage()
181
235
  onEvent?.(ConnectorSDKEventType.ExitError, metadata)
182
236
  onExit?.(ConnectorSDKEventType.ExitError, metadata)
183
237
  onExitError?.(metadata)
184
238
  break
185
239
  case 'ExitSuccess':
240
+ console.log('Event: ExitSuccess')
186
241
  clearLocalStorage()
187
242
  onEvent?.(ConnectorSDKEventType.ExitSuccess, metadata)
188
243
  onExit?.(ConnectorSDKEventType.ExitSuccess, metadata)
189
244
  onExitSuccess?.(metadata)
190
245
  break
191
246
  case 'Authenticate':
247
+ console.log('Event: Authenticate')
192
248
  // TODO: handle Authenticate
193
249
  break
194
- case 'OauthRequested':
195
- handleOAuthUrl(new URL(url.searchParams.get('oauthUrl') as string))
250
+ case 'OauthRequested': {
251
+ console.log('Event: OauthRequested')
252
+ const oauthUrl = url.searchParams.get('oauthUrl')
253
+
254
+ if (oauthUrl) {
255
+ if (isEncoded(oauthUrl)) {
256
+ try {
257
+ const decodedUrl = decodeURIComponent(oauthUrl)
258
+ handleOAuthUrl(decodedUrl)
259
+ } catch (error) {
260
+ console.error('OAuth URL decoding failed, using original')
261
+ handleOAuthUrl(oauthUrl)
262
+ }
263
+ } else {
264
+ handleOAuthUrl(oauthUrl)
265
+ }
266
+ } else {
267
+ console.error('OAuth URL missing from request')
268
+ }
196
269
  break
270
+ }
197
271
  default:
198
- console.log('unhandled event', url)
272
+ console.log(`Unhandled event: ${eventType}`)
199
273
  break
200
274
  }
201
275
  })
@@ -1 +1,2 @@
1
1
  export * from './error'
2
+ export * from './url'
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Checks if a string appears to be already URL encoded
3
+ * @param str The string to check
4
+ * @returns boolean indicating if the string appears to be URL encoded
5
+ */
6
+ export const isEncoded = (str: string): boolean => {
7
+ // Check for typical URL encoding patterns like %20, %3A, etc.
8
+ const hasEncodedChars = /%[0-9A-F]{2}/i.test(str)
9
+
10
+ // Check if double encoding has occurred (e.g., %253A instead of %3A)
11
+ const hasDoubleEncoding = /%25[0-9A-F]{2}/i.test(str)
12
+
13
+ // If we have encoded chars but no double encoding, it's likely properly encoded
14
+ return hasEncodedChars && !hasDoubleEncoding
15
+ }
16
+
17
+ /**
18
+ * Smart URL encoder that ensures a string is encoded exactly once
19
+ * @param str The string to encode
20
+ * @returns A properly URL encoded string
21
+ */
22
+ export const smartEncodeURIComponent = (str: string): string => {
23
+ if (!str) return str
24
+
25
+ // If it's already encoded, return as is
26
+ if (isEncoded(str)) {
27
+ console.log('URL already encoded, skipping encoding:', str)
28
+ return str
29
+ }
30
+
31
+ // Otherwise, encode it
32
+ const encoded = encodeURIComponent(str)
33
+ console.log('URL encoded from:', str, 'to:', encoded)
34
+ return encoded
35
+ }
36
+
37
+ /**
38
+ * Creates a URL with proper parameter encoding
39
+ * @param baseUrl The base URL string
40
+ * @param params Object containing key-value pairs to be added as search params
41
+ * @returns A properly formatted URL string
42
+ */
43
+ export const createUrlWithParams = (baseUrl: string, params: Record<string, string>): string => {
44
+ try {
45
+ const url = new URL(baseUrl)
46
+
47
+ // Add each parameter without additional encoding
48
+ // (URLSearchParams.append will encode them automatically)
49
+ Object.entries(params).forEach(([key, value]) => {
50
+ // Skip undefined or null values
51
+ if (value == null) return
52
+
53
+ // For oauth_redirect_url specifically, ensure it's not double encoded
54
+ if (key === 'oauth_redirect_url' && isEncoded(value)) {
55
+ // Decode once to counteract the automatic encoding that will happen
56
+ const decodedOnce = decodeURIComponent(value)
57
+ url.searchParams.append(key, decodedOnce)
58
+ } else {
59
+ url.searchParams.append(key, value)
60
+ }
61
+ })
62
+
63
+ return url.toString()
64
+ } catch (error) {
65
+ console.error('Error creating URL with params:', error)
66
+ return baseUrl
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Checks if a string appears to be double-encoded
72
+ */
73
+ const isDoubleEncoded = (str: string): boolean => {
74
+ if (!str) return false
75
+ return /%25[0-9A-F]{2}/i.test(str)
76
+ }
77
+
78
+ /**
79
+ * Normalizes a URL string by decoding it once if it appears to be double-encoded
80
+ */
81
+ export const normalizeUrlEncoding = (urlStr: string): string => {
82
+ if (isDoubleEncoded(urlStr)) {
83
+ console.log('Detected double-encoded URL:', urlStr)
84
+ const normalized = decodeURIComponent(urlStr)
85
+ console.log('Normalized to:', normalized)
86
+ return normalized
87
+ }
88
+ return urlStr
89
+ }