@basictech/react 0.7.0-beta.4 → 0.7.0-beta.6

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.
@@ -16,6 +16,7 @@ export type { BasicStorage, LocalStorageAdapter } from './utils/storage'
16
16
  export type AuthConfig = {
17
17
  scopes?: string | string[];
18
18
  server_url?: string;
19
+ ws_url?: string;
19
20
  }
20
21
 
21
22
  export type BasicProviderProps = {
@@ -27,15 +28,16 @@ export type BasicProviderProps = {
27
28
  auth?: AuthConfig;
28
29
  }
29
30
 
30
- const DEFAULT_AUTH_CONFIG: Required<AuthConfig> = {
31
- scopes: 'profile email app:admin',
32
- server_url: 'https://api.basic.tech'
33
- }
31
+ const DEFAULT_AUTH_CONFIG = {
32
+ scopes: 'profile,email,app:admin',
33
+ server_url: 'https://api.basic.tech',
34
+ ws_url: 'wss://pds.basic.id/ws'
35
+ } as const
34
36
 
35
37
 
36
38
  type BasicSyncType = {
37
39
  basic_schema: any;
38
- connect: (options: { access_token: string }) => void;
40
+ connect: (options: { access_token: string; ws_url?: string }) => void;
39
41
  debugeroo: () => void;
40
42
  collection: (name: string) => {
41
43
  ref: {
@@ -128,9 +130,10 @@ export function BasicProvider({
128
130
  const storageAdapter = storage || new LocalStorageAdapter();
129
131
 
130
132
  // Merge auth config with defaults
131
- const authConfig: Required<AuthConfig> = {
133
+ const authConfig = {
132
134
  scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
133
- server_url: auth?.server_url || DEFAULT_AUTH_CONFIG.server_url
135
+ server_url: auth?.server_url || DEFAULT_AUTH_CONFIG.server_url,
136
+ ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url
134
137
  }
135
138
 
136
139
  // Normalize scopes to space-separated string
@@ -138,6 +141,9 @@ export function BasicProvider({
138
141
  ? authConfig.scopes.join(' ')
139
142
  : authConfig.scopes;
140
143
 
144
+ // Token refresh mutex to prevent concurrent refreshes
145
+ const refreshPromiseRef = useRef<Promise<Token | null> | null>(null);
146
+
141
147
  const isDevMode = () => isDevelopment(debug)
142
148
 
143
149
  const cleanOAuthParams = () => cleanOAuthParamsFromUrl()
@@ -245,7 +251,10 @@ export function BasicProvider({
245
251
 
246
252
  log('connecting to db...')
247
253
 
248
- syncRef.current?.connect({ access_token: tok })
254
+ syncRef.current?.connect({
255
+ access_token: tok,
256
+ ws_url: authConfig.ws_url
257
+ })
249
258
  .catch((e) => {
250
259
  log('error connecting to db', e)
251
260
  })
@@ -259,6 +268,19 @@ export function BasicProvider({
259
268
  const initializeAuth = async () => {
260
269
  await storageAdapter.set(STORAGE_KEYS.DEBUG, debug ? 'true' : 'false')
261
270
 
271
+ // Check if server URL has changed - if so, clear tokens
272
+ const storedServerUrl = await storageAdapter.get(STORAGE_KEYS.SERVER_URL)
273
+ if (storedServerUrl && storedServerUrl !== authConfig.server_url) {
274
+ log('Server URL changed, clearing stored tokens')
275
+ await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
276
+ await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
277
+ await storageAdapter.remove(STORAGE_KEYS.AUTH_STATE)
278
+ await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
279
+ clearCookie('basic_token')
280
+ clearCookie('basic_access_token')
281
+ }
282
+ await storageAdapter.set(STORAGE_KEYS.SERVER_URL, authConfig.server_url)
283
+
262
284
  try {
263
285
  const versionUpdater = createVersionUpdater(storageAdapter, currentVersion, getMigrations())
264
286
  const updateResult = await versionUpdater.checkAndUpdate()
@@ -337,19 +359,25 @@ export function BasicProvider({
337
359
  useEffect(() => {
338
360
  async function fetchUser(acc_token: string) {
339
361
  console.info('fetching user')
340
- const user = await fetch(`${authConfig.server_url}/auth/userInfo`, {
341
- method: 'GET',
342
- headers: {
343
- 'Authorization': `Bearer ${acc_token}`
362
+ try {
363
+ const response = await fetch(`${authConfig.server_url}/auth/userInfo`, {
364
+ method: 'GET',
365
+ headers: {
366
+ 'Authorization': `Bearer ${acc_token}`
367
+ }
368
+ })
369
+
370
+ if (!response.ok) {
371
+ throw new Error(`Failed to fetch user info: ${response.status}`)
372
+ }
373
+
374
+ const user = await response.json()
375
+
376
+ if (user.error) {
377
+ log('error fetching user', user.error)
378
+ throw new Error(`User info error: ${user.error}`)
344
379
  }
345
- })
346
- .then(response => response.json())
347
- .catch(error => log('Error:', error))
348
380
 
349
- if (user.error) {
350
- log('error fetching user', user.error)
351
- return
352
- } else {
353
381
  if (token?.refresh_token) {
354
382
  await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
355
383
  }
@@ -362,7 +390,10 @@ export function BasicProvider({
362
390
 
363
391
  setUser(user)
364
392
  setIsSignedIn(true)
365
-
393
+ setIsAuthReady(true)
394
+ } catch (error) {
395
+ log('Failed to fetch user info:', error)
396
+ // Don't clear tokens here - may be temporary network issue
366
397
  setIsAuthReady(true)
367
398
  }
368
399
  }
@@ -376,12 +407,20 @@ export function BasicProvider({
376
407
  }
377
408
 
378
409
  const decoded = jwtDecode(token?.access_token)
379
- const isExpired = decoded.exp && decoded.exp < Date.now() / 1000
410
+ // Add 5 second buffer to prevent edge cases
411
+ const expirationBuffer = 5
412
+ const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
380
413
 
381
414
  if (isExpired) {
382
415
  log('token is expired - refreshing ...')
416
+ const refreshToken = token?.refresh_token
417
+ if (!refreshToken) {
418
+ log('Error: No refresh token available for expired token')
419
+ setIsAuthReady(true)
420
+ return
421
+ }
383
422
  try {
384
- const newToken = await fetchToken(token?.refresh_token || '', true)
423
+ const newToken = await fetchToken(refreshToken, true)
385
424
  fetchUser(newToken?.access_token || '')
386
425
  } catch (error) {
387
426
  log('Failed to refresh token in checkToken:', error)
@@ -452,7 +491,10 @@ export function BasicProvider({
452
491
  const signInLink = await getSignInLink()
453
492
  log('Generated sign-in link:', signInLink)
454
493
 
455
- if (!signInLink || !signInLink.startsWith('https://')) {
494
+ // Validate URL format (supports https://, http://, and custom URI schemes)
495
+ try {
496
+ new URL(signInLink)
497
+ } catch {
456
498
  log('Error: Invalid sign-in link generated')
457
499
  throw new Error('Failed to generate valid sign-in URL')
458
500
  }
@@ -522,6 +564,7 @@ export function BasicProvider({
522
564
  await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
523
565
  await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
524
566
  await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
567
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
525
568
  if (syncRef.current) {
526
569
  (async () => {
527
570
  try {
@@ -544,6 +587,21 @@ export function BasicProvider({
544
587
  const refreshToken = await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
545
588
  if (refreshToken) {
546
589
  log('No token in memory, attempting to refresh from storage')
590
+
591
+ // Check if refresh is already in progress
592
+ if (refreshPromiseRef.current) {
593
+ log('Token refresh already in progress, waiting...')
594
+ try {
595
+ const newToken = await refreshPromiseRef.current
596
+ if (newToken?.access_token) {
597
+ return newToken.access_token
598
+ }
599
+ } catch (error) {
600
+ log('In-flight refresh failed:', error)
601
+ throw error
602
+ }
603
+ }
604
+
547
605
  try {
548
606
  const newToken = await fetchToken(refreshToken, true)
549
607
  if (newToken?.access_token) {
@@ -569,10 +627,31 @@ export function BasicProvider({
569
627
  }
570
628
 
571
629
  const decoded = jwtDecode(token?.access_token)
572
- const isExpired = decoded.exp && decoded.exp < Date.now() / 1000
630
+ // Add 5 second buffer to prevent edge cases where token expires during request
631
+ const expirationBuffer = 5
632
+ const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
573
633
 
574
634
  if (isExpired) {
575
635
  log('token is expired - refreshing ...')
636
+
637
+ // Check if refresh is already in progress
638
+ if (refreshPromiseRef.current) {
639
+ log('Token refresh already in progress, waiting...')
640
+ try {
641
+ const newToken = await refreshPromiseRef.current
642
+ return newToken?.access_token || ''
643
+ } catch (error) {
644
+ log('In-flight refresh failed:', error)
645
+
646
+ if ((error as Error).message.includes('offline') || (error as Error).message.includes('Network')) {
647
+ log('Network issue - using expired token until network is restored')
648
+ return token.access_token
649
+ }
650
+
651
+ throw error
652
+ }
653
+ }
654
+
576
655
  const refreshToken = token?.refresh_token || await storageAdapter.get(STORAGE_KEYS.REFRESH_TOKEN)
577
656
  if (refreshToken) {
578
657
  try {
@@ -596,124 +675,158 @@ export function BasicProvider({
596
675
  return token?.access_token || ''
597
676
  }
598
677
 
599
- const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false) => {
600
- try {
601
- if (!isOnline) {
602
- log('Network is offline, marking refresh as pending')
603
- setPendingRefresh(true)
604
- throw new Error('Network offline - refresh will be retried when online')
605
- }
678
+ const fetchToken = async (codeOrRefreshToken: string, isRefreshToken: boolean = false): Promise<Token | null> => {
679
+ // Validate input
680
+ if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
681
+ const errorMsg = isRefreshToken ? 'Refresh token is empty or undefined' : 'Authorization code is empty or undefined'
682
+ log('Error:', errorMsg)
683
+ throw new Error(errorMsg)
684
+ }
606
685
 
607
- let requestBody: any
686
+ // If this is a refresh token request and one is already in progress, return that promise
687
+ if (isRefreshToken && refreshPromiseRef.current) {
688
+ log('Reusing in-flight refresh token request')
689
+ return refreshPromiseRef.current
690
+ }
608
691
 
609
- if (isRefreshToken) {
610
- // Refresh token request
611
- requestBody = {
612
- grant_type: 'refresh_token',
613
- refresh_token: codeOrRefreshToken
614
- }
615
- // Include client_id if available for validation
616
- if (project_id) {
617
- requestBody.client_id = project_id
618
- }
619
- } else {
620
- // Authorization code exchange
621
- requestBody = {
622
- grant_type: 'authorization_code',
623
- code: codeOrRefreshToken
692
+ // Create new promise for this refresh attempt
693
+ const refreshPromise = (async (): Promise<Token | null> => {
694
+ try {
695
+ if (!isOnline) {
696
+ log('Network is offline, marking refresh as pending')
697
+ setPendingRefresh(true)
698
+ throw new Error('Network offline - refresh will be retried when online')
624
699
  }
625
-
626
- // Retrieve stored redirect_uri (required by OAuth2 spec)
627
- const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
628
- if (storedRedirectUri) {
629
- requestBody.redirect_uri = storedRedirectUri
630
- log('Including redirect_uri in token exchange:', storedRedirectUri)
700
+
701
+ let requestBody: any
702
+
703
+ if (isRefreshToken) {
704
+ // Refresh token request
705
+ requestBody = {
706
+ grant_type: 'refresh_token',
707
+ refresh_token: codeOrRefreshToken
708
+ }
709
+ // Include client_id if available for validation
710
+ if (project_id) {
711
+ requestBody.client_id = project_id
712
+ }
631
713
  } else {
632
- log('Warning: No redirect_uri found in storage for token exchange')
633
- }
634
-
635
- // Include client_id for validation
636
- if (project_id) {
637
- requestBody.client_id = project_id
714
+ // Authorization code exchange
715
+ requestBody = {
716
+ grant_type: 'authorization_code',
717
+ code: codeOrRefreshToken
718
+ }
719
+
720
+ // Retrieve stored redirect_uri (required by OAuth2 spec)
721
+ const storedRedirectUri = await storageAdapter.get(STORAGE_KEYS.REDIRECT_URI)
722
+ if (storedRedirectUri) {
723
+ requestBody.redirect_uri = storedRedirectUri
724
+ log('Including redirect_uri in token exchange:', storedRedirectUri)
725
+ } else {
726
+ log('Warning: No redirect_uri found in storage for token exchange')
727
+ }
728
+
729
+ // Include client_id for validation
730
+ if (project_id) {
731
+ requestBody.client_id = project_id
732
+ }
638
733
  }
639
- }
640
734
 
641
- log('Token exchange request body:', { ...requestBody, refresh_token: isRefreshToken ? '[REDACTED]' : undefined, code: !isRefreshToken ? '[REDACTED]' : undefined })
735
+ log('Token exchange request body:', { ...requestBody, refresh_token: isRefreshToken ? '[REDACTED]' : undefined, code: !isRefreshToken ? '[REDACTED]' : undefined })
642
736
 
643
- const token = await fetch(`${authConfig.server_url}/auth/token`, {
644
- method: 'POST',
645
- headers: {
646
- 'Content-Type': 'application/json'
647
- },
648
- body: JSON.stringify(requestBody)
649
- })
650
- .then(response => response.json())
651
- .catch(error => {
652
- log('Network error fetching token:', error)
653
- if (!isOnline) {
737
+ const token = await fetch(`${authConfig.server_url}/auth/token`, {
738
+ method: 'POST',
739
+ headers: {
740
+ 'Content-Type': 'application/json'
741
+ },
742
+ body: JSON.stringify(requestBody)
743
+ })
744
+ .then(response => response.json())
745
+ .catch(error => {
746
+ log('Network error fetching token:', error)
747
+ if (!isOnline) {
748
+ setPendingRefresh(true)
749
+ throw new Error('Network offline - refresh will be retried when online')
750
+ }
751
+ throw new Error('Network error during token refresh')
752
+ })
753
+
754
+ if (token.error) {
755
+ log('error fetching token', token.error)
756
+
757
+ if (token.error.includes('network') || token.error.includes('timeout')) {
654
758
  setPendingRefresh(true)
655
- throw new Error('Network offline - refresh will be retried when online')
759
+ throw new Error('Network issue - refresh will be retried when online')
656
760
  }
657
- throw new Error('Network error during token refresh')
658
- })
659
761
 
660
- if (token.error) {
661
- log('error fetching token', token.error)
762
+ await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
763
+ await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
764
+ await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
765
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
766
+ clearCookie('basic_token');
767
+ clearCookie('basic_access_token');
662
768
 
663
- if (token.error.includes('network') || token.error.includes('timeout')) {
664
- setPendingRefresh(true)
665
- throw new Error('Network issue - refresh will be retried when online')
666
- }
769
+ setUser({})
770
+ setIsSignedIn(false)
771
+ setToken(null)
772
+ setIsAuthReady(true)
667
773
 
668
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
669
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
670
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
671
- clearCookie('basic_token');
672
- clearCookie('basic_access_token');
774
+ throw new Error(`Token refresh failed: ${token.error}`)
775
+ } else {
776
+ setToken(token)
777
+ setPendingRefresh(false)
673
778
 
674
- setUser({})
675
- setIsSignedIn(false)
676
- setToken(null)
677
- setIsAuthReady(true)
779
+ if (token.refresh_token) {
780
+ await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
781
+ log('Updated refresh token in storage')
782
+ }
678
783
 
679
- throw new Error(`Token refresh failed: ${token.error}`)
680
- } else {
681
- setToken(token)
682
- setPendingRefresh(false)
784
+ // Clean up redirect_uri after successful token exchange
785
+ if (!isRefreshToken) {
786
+ await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
787
+ log('Cleaned up redirect_uri from storage after successful exchange')
788
+ }
683
789
 
684
- if (token.refresh_token) {
685
- await storageAdapter.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
686
- log('Updated refresh token in storage')
790
+ setCookie('basic_access_token', token.access_token, { httpOnly: false });
791
+ setCookie('basic_token', JSON.stringify(token));
792
+ log('Updated access token and full token in cookies')
687
793
  }
794
+ return token
795
+ } catch (error) {
796
+ log('Token refresh error:', error)
688
797
 
689
- // Clean up redirect_uri after successful token exchange
690
- if (!isRefreshToken) {
798
+ if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
799
+ await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
800
+ await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
691
801
  await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
692
- log('Cleaned up redirect_uri from storage after successful exchange')
802
+ await storageAdapter.remove(STORAGE_KEYS.SERVER_URL)
803
+ clearCookie('basic_token');
804
+ clearCookie('basic_access_token');
805
+
806
+ setUser({})
807
+ setIsSignedIn(false)
808
+ setToken(null)
809
+ setIsAuthReady(true)
693
810
  }
694
811
 
695
- setCookie('basic_access_token', token.access_token, { httpOnly: false });
696
- log('Updated access token in cookie')
812
+ throw error
697
813
  }
698
- return token
699
- } catch (error) {
700
- log('Token refresh error:', error)
701
-
702
- if (!(error as Error).message.includes('offline') && !(error as Error).message.includes('Network')) {
703
- await storageAdapter.remove(STORAGE_KEYS.REFRESH_TOKEN)
704
- await storageAdapter.remove(STORAGE_KEYS.USER_INFO)
705
- await storageAdapter.remove(STORAGE_KEYS.REDIRECT_URI)
706
- clearCookie('basic_token');
707
- clearCookie('basic_access_token');
708
-
709
- setUser({})
710
- setIsSignedIn(false)
711
- setToken(null)
712
- setIsAuthReady(true)
713
- }
714
-
715
- throw error
814
+ })()
815
+
816
+ // Store promise if this is a refresh token request
817
+ if (isRefreshToken) {
818
+ refreshPromiseRef.current = refreshPromise
819
+
820
+ // Clear the promise reference when done (success or failure)
821
+ refreshPromise.finally(() => {
822
+ if (refreshPromiseRef.current === refreshPromise) {
823
+ refreshPromiseRef.current = null
824
+ log('Cleared refresh promise reference')
825
+ }
826
+ })
716
827
  }
828
+
829
+ return refreshPromise
717
830
  }
718
831
 
719
832
  const noDb = ({
package/src/sync/index.ts CHANGED
@@ -56,8 +56,8 @@ export class BasicSync extends Dexie {
56
56
 
57
57
  }
58
58
 
59
- async connect({ access_token }: { access_token: string }) {
60
- const WS_URL = `wss://pds.basic.id/ws`
59
+ async connect({ access_token, ws_url }: { access_token: string, ws_url?: string }) {
60
+ const WS_URL = ws_url || 'wss://pds.basic.id/ws'
61
61
 
62
62
  log('Connecting to', WS_URL)
63
63
 
@@ -67,8 +67,8 @@ export class BasicSync extends Dexie {
67
67
  return this.syncable.connect("websocket", WS_URL, { authToken: access_token, schema: this.basic_schema });
68
68
  }
69
69
 
70
- async disconnect() {
71
- const WS_URL = `wss://pds.basic.id/ws`
70
+ async disconnect({ ws_url }: { ws_url?: string } = {}) {
71
+ const WS_URL = ws_url || 'wss://pds.basic.id/ws'
72
72
 
73
73
  return this.syncable.disconnect(WS_URL)
74
74
  }
@@ -24,6 +24,7 @@ export const STORAGE_KEYS = {
24
24
  USER_INFO: 'basic_user_info',
25
25
  AUTH_STATE: 'basic_auth_state',
26
26
  REDIRECT_URI: 'basic_redirect_uri',
27
+ SERVER_URL: 'basic_server_url',
27
28
  DEBUG: 'basic_debug'
28
29
  } as const
29
30