@basictech/react 0.7.0 → 0.8.0-beta.1

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.
@@ -0,0 +1,858 @@
1
+ import { jwtDecode } from 'jwt-decode'
2
+ import { BasicStorage, STORAGE_KEYS } from '../../utils/storage'
3
+ import { normalizeClientId } from '../../utils/normalizeClientId'
4
+ import { resolveHandle } from '../../utils/resolveDid'
5
+ import { cleanOAuthParamsFromUrl } from '../../utils/network'
6
+ import { log } from '../../config'
7
+
8
+ // --- PKCE helpers (RFC 7636) ---
9
+
10
+ function generateCodeVerifier(): string {
11
+ const array = new Uint8Array(32)
12
+ crypto.getRandomValues(array)
13
+ return base64UrlEncode(array)
14
+ }
15
+
16
+ async function generateCodeChallenge(verifier: string): Promise<{ challenge: string; method: 'S256' | 'plain' }> {
17
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
18
+ log('crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge')
19
+ return { challenge: verifier, method: 'plain' }
20
+ }
21
+ const encoder = new TextEncoder()
22
+ const data = encoder.encode(verifier)
23
+ const digest = await crypto.subtle.digest('SHA-256', data)
24
+ return { challenge: base64UrlEncode(new Uint8Array(digest)), method: 'S256' }
25
+ }
26
+
27
+ function base64UrlEncode(buffer: Uint8Array): string {
28
+ let str = ''
29
+ for (let i = 0; i < buffer.length; i++) {
30
+ str += String.fromCharCode(buffer[i]!)
31
+ }
32
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
33
+ }
34
+
35
+ export type Token = {
36
+ access_token: string
37
+ token_type: string
38
+ expires_in: number
39
+ refresh_token: string
40
+ }
41
+
42
+ export type User = {
43
+ sub?: string
44
+ name?: string
45
+ email?: string
46
+ picture?: string
47
+ }
48
+
49
+ export type AuthResult = {
50
+ success: boolean
51
+ error?: string
52
+ code?: string
53
+ }
54
+
55
+ export type GetTokenOptions = {
56
+ forceRefresh?: boolean
57
+ }
58
+
59
+ export type PdsEndpoints = {
60
+ pds_url: string
61
+ authorization_endpoint: string
62
+ token_endpoint: string
63
+ userinfo_endpoint: string
64
+ }
65
+
66
+ export type AuthManagerConfig = {
67
+ projectId: string | undefined
68
+ scopes: string
69
+ pdsUrl: string
70
+ adminUrl: string
71
+ debug: boolean
72
+ }
73
+
74
+ /**
75
+ * Framework-agnostic auth manager. Holds token state, handles OAuth flow,
76
+ * token refresh (with mutex), and user info fetching.
77
+ *
78
+ * React integration: pass a state-setter as `notify` so the component
79
+ * re-renders whenever auth state changes.
80
+ */
81
+ export class AuthManager {
82
+ // --- Public state (read by the UI layer) ---
83
+ token: Token | null = null
84
+ user: User | null = null
85
+ isSignedIn: boolean = false
86
+ isAuthReady: boolean = false
87
+ did: string | null = null
88
+ /** Space-separated scopes granted in the current access token */
89
+ tokenScope: string | null = null
90
+ /** Space-separated scopes originally requested in the auth config */
91
+ requestedScopes: string
92
+
93
+ readonly config: AuthManagerConfig
94
+ readonly storage: BasicStorage
95
+
96
+ /** True only during a user-initiated OAuth code exchange (not session restore) */
97
+ private freshSignIn: boolean = false
98
+
99
+ // --- Private ---
100
+ private notify: () => void
101
+ private refreshPromise: Promise<Token | null> | null = null
102
+ private codeExchangePromise: Promise<Token | null> | null = null
103
+ private pendingRefresh: boolean = false
104
+ private isOnline: boolean = typeof navigator !== 'undefined' ? navigator.onLine : true
105
+ private channel: BroadcastChannel | null = null
106
+
107
+ constructor(config: AuthManagerConfig, storage: BasicStorage, notify: () => void) {
108
+ this.config = config
109
+ this.storage = storage
110
+ this.notify = notify
111
+ this.requestedScopes = config.scopes
112
+ this.initCrossTabSync()
113
+ }
114
+
115
+ private initCrossTabSync(): void {
116
+ if (typeof BroadcastChannel === 'undefined') return
117
+
118
+ try {
119
+ this.channel = new BroadcastChannel('basic-auth')
120
+ this.channel.onmessage = (event) => {
121
+ if (event.data?.type === 'token_refreshed') {
122
+ log('Received token refresh from another tab')
123
+ if (event.data.accessToken && this.token) {
124
+ this.token = { ...this.token, access_token: event.data.accessToken }
125
+ }
126
+ if (event.data.did) this.did = event.data.did
127
+ if (event.data.tokenScope) this.tokenScope = event.data.tokenScope
128
+ this.notify()
129
+ }
130
+ if (event.data?.type === 'signed_in') {
131
+ log('Received sign-in from another tab, reloading')
132
+ // The signing-in tab already stored refresh_token and user_info
133
+ // in localStorage. Reload so initialize() bootstraps the session.
134
+ if (typeof window !== 'undefined') {
135
+ window.location.reload()
136
+ }
137
+ }
138
+ if (event.data?.type === 'signed_out') {
139
+ log('Received sign-out from another tab, reloading')
140
+ this.user = null
141
+ this.isSignedIn = false
142
+ this.token = null
143
+ this.did = null
144
+ this.tokenScope = null
145
+ this.notify()
146
+ // Storage and IndexedDB are already cleaned by the tab that initiated
147
+ // sign-out. Reload so this tab picks up the clean slate.
148
+ if (typeof window !== 'undefined') {
149
+ window.location.reload()
150
+ }
151
+ }
152
+ }
153
+ } catch {
154
+ log('BroadcastChannel not available for cross-tab sync')
155
+ }
156
+ }
157
+
158
+ private broadcastTokenRefresh(): void {
159
+ this.channel?.postMessage({
160
+ type: 'token_refreshed',
161
+ accessToken: this.token?.access_token,
162
+ did: this.did,
163
+ tokenScope: this.tokenScope,
164
+ })
165
+ }
166
+
167
+ private broadcastSignIn(): void {
168
+ this.channel?.postMessage({ type: 'signed_in' })
169
+ }
170
+
171
+ private broadcastSignOut(): void {
172
+ this.channel?.postMessage({ type: 'signed_out' })
173
+ }
174
+
175
+ // ------------------------------------------------------------------
176
+ // Public API
177
+ // ------------------------------------------------------------------
178
+
179
+ /**
180
+ * Bootstrap auth: handle OAuth callback (?code=), restore session
181
+ * from refresh token, or load cached user for offline mode.
182
+ */
183
+ async initialize(): Promise<void> {
184
+ await this.storage.set(STORAGE_KEYS.DEBUG, this.config.debug ? 'true' : 'false')
185
+
186
+ const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL)
187
+ if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
188
+ log('PDS URL changed, clearing stored tokens')
189
+ await this.clearStoredAuth()
190
+ }
191
+ await this.storage.set(STORAGE_KEYS.SERVER_URL, this.config.pdsUrl)
192
+
193
+ try {
194
+ const params = new URLSearchParams(window.location.search)
195
+
196
+ if (params.has('code')) {
197
+ const code = params.get('code')
198
+ if (!code) {
199
+ this.isAuthReady = true
200
+ this.notify()
201
+ return
202
+ }
203
+
204
+ const state = await this.storage.get(STORAGE_KEYS.AUTH_STATE)
205
+ const urlState = params.get('state')
206
+ if (!state || state !== urlState) {
207
+ log('error: auth state does not match')
208
+ this.isAuthReady = true
209
+ this.notify()
210
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
211
+ cleanOAuthParamsFromUrl()
212
+ return
213
+ }
214
+
215
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
216
+ cleanOAuthParamsFromUrl()
217
+
218
+ this.freshSignIn = true
219
+ this.exchangeToken(code, false).catch((error) => {
220
+ log('Error fetching token:', error)
221
+ })
222
+ } else {
223
+ const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
224
+ if (refreshToken) {
225
+ log('Found refresh token in storage, attempting to refresh access token')
226
+ this.exchangeToken(refreshToken, true).catch((error) => {
227
+ log('Error fetching refresh token:', error)
228
+ })
229
+ } else {
230
+ const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO)
231
+ if (cachedUserInfo) {
232
+ try {
233
+ this.user = JSON.parse(cachedUserInfo)
234
+ this.isSignedIn = true
235
+ log('Loaded cached user info for offline mode')
236
+ } catch (error) {
237
+ log('Error parsing cached user info:', error)
238
+ }
239
+ }
240
+ this.isAuthReady = true
241
+ this.notify()
242
+ }
243
+ }
244
+ } catch (e) {
245
+ log('error getting token', e)
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Get a valid access token string. Refreshes proactively (5s buffer)
251
+ * or on demand (forceRefresh). Mutex prevents concurrent refreshes.
252
+ */
253
+ async getToken(options?: GetTokenOptions): Promise<string> {
254
+ log('getting token...')
255
+
256
+ if (!this.token) {
257
+ const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
258
+ if (refreshToken) {
259
+ log('No token in memory, attempting to refresh from storage')
260
+
261
+ if (this.refreshPromise) {
262
+ log('Token refresh already in progress, waiting...')
263
+ try {
264
+ const newToken = await this.refreshPromise
265
+ if (newToken?.access_token) {
266
+ return newToken.access_token
267
+ }
268
+ } catch (error) {
269
+ log('In-flight refresh failed:', error)
270
+ throw error
271
+ }
272
+ }
273
+
274
+ try {
275
+ const newToken = await this.exchangeToken(refreshToken, true)
276
+ if (newToken?.access_token) {
277
+ return newToken.access_token
278
+ }
279
+ } catch (error) {
280
+ log('Failed to refresh token from storage:', error)
281
+ if (this.isNetworkError(error)) {
282
+ throw new Error('Network offline - authentication will be retried when online')
283
+ }
284
+ throw new Error('Authentication expired. Please sign in again.')
285
+ }
286
+ }
287
+ log('no token found')
288
+ throw new Error('no token found')
289
+ }
290
+
291
+ const decoded = jwtDecode(this.token.access_token)
292
+ const expirationBuffer = 5
293
+ const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
294
+ const shouldRefresh = isExpired || options?.forceRefresh === true
295
+
296
+ if (shouldRefresh) {
297
+ log(options?.forceRefresh ? 'force refreshing token...' : 'token is expired - refreshing ...')
298
+
299
+ if (this.refreshPromise) {
300
+ log('Token refresh already in progress, waiting...')
301
+ try {
302
+ const newToken = await this.refreshPromise
303
+ return newToken?.access_token || ''
304
+ } catch (error) {
305
+ log('In-flight refresh failed:', error)
306
+ if (this.isNetworkError(error)) {
307
+ log('Network issue - using expired token until network is restored')
308
+ return this.token.access_token
309
+ }
310
+ throw error
311
+ }
312
+ }
313
+
314
+ const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
315
+ if (refreshToken) {
316
+ try {
317
+ const newToken = await this.exchangeToken(refreshToken, true)
318
+ return newToken?.access_token || ''
319
+ } catch (error) {
320
+ log('Failed to refresh expired token:', error)
321
+ if (this.isNetworkError(error)) {
322
+ log('Network issue - using expired token until network is restored')
323
+ return this.token.access_token
324
+ }
325
+ throw new Error('Authentication expired. Please sign in again.')
326
+ }
327
+ } else {
328
+ throw new Error('no refresh token available')
329
+ }
330
+ }
331
+
332
+ return this.token.access_token || ''
333
+ }
334
+
335
+ async getSignInUrl(redirectUri?: string, endpoints?: PdsEndpoints): Promise<string> {
336
+ log('getting sign in link...')
337
+
338
+ if (!this.config.projectId) {
339
+ throw new Error('Project ID is required to generate sign-in link')
340
+ }
341
+
342
+ const pdsEndpoints = endpoints || this.defaultPdsEndpoints()
343
+ await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints))
344
+
345
+ const randomState = Math.random().toString(36).substring(6)
346
+ await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState)
347
+
348
+ const redirectUrl = redirectUri || window.location.href
349
+ if (!redirectUrl || (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://'))) {
350
+ throw new Error('Invalid redirect URI provided')
351
+ }
352
+
353
+ await this.storage.set(STORAGE_KEYS.REDIRECT_URI, redirectUrl)
354
+ log('Stored redirect_uri for token exchange:', redirectUrl)
355
+
356
+ // PKCE: generate code_verifier and code_challenge (RFC 7636)
357
+ const codeVerifier = generateCodeVerifier()
358
+ const { challenge: codeChallenge, method: challengeMethod } = await generateCodeChallenge(codeVerifier)
359
+ await this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier)
360
+
361
+ let baseUrl = pdsEndpoints.authorization_endpoint
362
+ baseUrl += `?client_id=${encodeURIComponent(normalizeClientId(this.config.projectId, this.adminHostname))}`
363
+ baseUrl += `&redirect_uri=${encodeURIComponent(redirectUrl)}`
364
+ baseUrl += `&response_type=code`
365
+ baseUrl += `&scope=${encodeURIComponent(this.config.scopes)}`
366
+ baseUrl += `&state=${randomState}`
367
+ baseUrl += `&code_challenge=${encodeURIComponent(codeChallenge)}`
368
+ baseUrl += `&code_challenge_method=${challengeMethod}`
369
+
370
+ log('Generated sign-in link successfully with scopes:', this.config.scopes)
371
+ return baseUrl
372
+ }
373
+
374
+ async signIn(redirectUri?: string): Promise<void> {
375
+ log('signing in...')
376
+
377
+ if (!this.config.projectId) {
378
+ log('Error: project_id is required for sign-in')
379
+ throw new Error('Project ID is required for authentication')
380
+ }
381
+
382
+ const signInLink = await this.getSignInUrl(redirectUri)
383
+ log('Generated sign-in link:', signInLink)
384
+
385
+ try { new URL(signInLink) } catch {
386
+ log('Error: Invalid sign-in link generated')
387
+ throw new Error('Failed to generate valid sign-in URL')
388
+ }
389
+
390
+ window.location.href = signInLink
391
+ }
392
+
393
+ async signInWithHandle(handle: string): Promise<void> {
394
+ log('signing in with handle:', handle)
395
+
396
+ if (!this.config.projectId) {
397
+ throw new Error('Project ID is required for authentication')
398
+ }
399
+
400
+ const resolved = await resolveHandle(handle)
401
+ log('Resolved handle to PDS:', resolved.pdsUrl)
402
+
403
+ const endpoints: PdsEndpoints = {
404
+ pds_url: resolved.pdsUrl,
405
+ authorization_endpoint: resolved.authorization_endpoint,
406
+ token_endpoint: resolved.token_endpoint,
407
+ userinfo_endpoint: resolved.userinfo_endpoint
408
+ }
409
+
410
+ const signInLink = await this.getSignInUrl(undefined, endpoints)
411
+ log('Generated federated sign-in link:', signInLink)
412
+
413
+ try { new URL(signInLink) } catch {
414
+ throw new Error('Failed to generate valid sign-in URL')
415
+ }
416
+
417
+ window.location.href = signInLink
418
+ }
419
+
420
+ async signInWithCode(code: string, state?: string): Promise<AuthResult> {
421
+ try {
422
+ log('signInWithCode called with code:', code)
423
+
424
+ if (!code || typeof code !== 'string') {
425
+ return { success: false, error: 'Invalid authorization code' }
426
+ }
427
+
428
+ if (state) {
429
+ const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE)
430
+ if (storedState && storedState !== state) {
431
+ log('State parameter mismatch:', { provided: state, stored: storedState })
432
+ return { success: false, error: 'State parameter mismatch' }
433
+ }
434
+ }
435
+
436
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
437
+ cleanOAuthParamsFromUrl()
438
+
439
+ this.freshSignIn = true
440
+ const token = await this.exchangeToken(code, false)
441
+ if (token) {
442
+ log('signInWithCode successful')
443
+ return { success: true }
444
+ } else {
445
+ return { success: false, error: 'Failed to exchange code for token' }
446
+ }
447
+ } catch (error) {
448
+ log('signInWithCode error:', error)
449
+ return {
450
+ success: false,
451
+ error: (error as Error).message || 'Authentication failed'
452
+ }
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Clear auth state and storage. Does NOT handle sync/DB cleanup —
458
+ * the UI layer (BasicProvider) wraps this to add sync teardown.
459
+ */
460
+ async signOut(): Promise<void> {
461
+ log('signing out!')
462
+ this.resetAuthState()
463
+
464
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
465
+ await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT)
466
+ await this.clearStoredAuth()
467
+
468
+ this.broadcastSignOut()
469
+ this.notify()
470
+ }
471
+
472
+ hasScope(scope: string): boolean {
473
+ if (!this.tokenScope) return false
474
+ return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope)
475
+ }
476
+
477
+ /**
478
+ * Returns scopes that were requested but not granted in the current token.
479
+ * Useful after login or when a 403 is returned.
480
+ */
481
+ missingScopes(): string[] {
482
+ const requested = this.requestedScopes.split(/[\s,]+/).filter(Boolean)
483
+ if (!this.tokenScope) return requested
484
+ const granted = new Set(this.tokenScope.split(/[\s,]+/).filter(Boolean))
485
+ return requested.filter(s => !granted.has(s))
486
+ }
487
+
488
+ /**
489
+ * Register online/offline handlers that retry pending refreshes.
490
+ * Returns a cleanup function for useEffect teardown.
491
+ */
492
+ setupNetworkListeners(): () => void {
493
+ const handleOnline = async () => {
494
+ log('Network came back online')
495
+ this.isOnline = true
496
+ if (this.pendingRefresh && this.token) {
497
+ log('Retrying pending token refresh')
498
+ this.pendingRefresh = false
499
+ const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
500
+ if (refreshToken) {
501
+ this.exchangeToken(refreshToken, true).catch(error => {
502
+ log('Retry refresh failed:', error)
503
+ })
504
+ }
505
+ }
506
+ }
507
+
508
+ const handleOffline = () => {
509
+ log('Network went offline')
510
+ this.isOnline = false
511
+ }
512
+
513
+ window.addEventListener('online', handleOnline)
514
+ window.addEventListener('offline', handleOffline)
515
+
516
+ return () => {
517
+ window.removeEventListener('online', handleOnline)
518
+ window.removeEventListener('offline', handleOffline)
519
+ }
520
+ }
521
+
522
+ // ------------------------------------------------------------------
523
+ // Private
524
+ // ------------------------------------------------------------------
525
+
526
+ private get adminHostname(): string {
527
+ try { return new URL(this.config.adminUrl).hostname }
528
+ catch { return 'api.basic.tech' }
529
+ }
530
+
531
+ private defaultPdsEndpoints(): PdsEndpoints {
532
+ return {
533
+ pds_url: this.config.pdsUrl,
534
+ authorization_endpoint: `${this.config.pdsUrl}/auth/authorize`,
535
+ token_endpoint: `${this.config.pdsUrl}/auth/token`,
536
+ userinfo_endpoint: `${this.config.pdsUrl}/auth/userinfo`
537
+ }
538
+ }
539
+
540
+ private async getActivePdsEndpoints(): Promise<PdsEndpoints> {
541
+ const stored = await this.storage.get(STORAGE_KEYS.PDS_ENDPOINTS)
542
+ if (stored) {
543
+ try { return JSON.parse(stored) as PdsEndpoints } catch { /* fall through */ }
544
+ }
545
+ return this.defaultPdsEndpoints()
546
+ }
547
+
548
+ private async reportConnection(accessToken: string): Promise<void> {
549
+ if (!this.config.projectId || !this.config.adminUrl) return
550
+ const lastReport = await this.storage.get(STORAGE_KEYS.LAST_CONNECT_REPORT)
551
+ if (lastReport) {
552
+ const elapsed = Date.now() - parseInt(lastReport, 10)
553
+ if (elapsed < 24 * 60 * 60 * 1000) return
554
+ }
555
+ try {
556
+ await fetch(`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`, {
557
+ method: 'POST',
558
+ headers: { 'Content-Type': 'application/json' },
559
+ body: JSON.stringify({ token: accessToken })
560
+ })
561
+ await this.storage.set(STORAGE_KEYS.LAST_CONNECT_REPORT, Date.now().toString())
562
+ log('Reported connection to admin server')
563
+ } catch (err) {
564
+ log('Failed to report connection (non-blocking):', err)
565
+ }
566
+ }
567
+
568
+ /**
569
+ * After a new token is stored, decode JWT claims and fetch user info.
570
+ */
571
+ private async processNewToken(): Promise<void> {
572
+ if (!this.token) {
573
+ this.isAuthReady = true
574
+ this.notify()
575
+ return
576
+ }
577
+
578
+ try {
579
+ const decoded = jwtDecode<{ sub?: string; scope?: string; typ?: string; exp?: number }>(this.token.access_token)
580
+
581
+ if (decoded.sub) this.did = decoded.sub
582
+ if (decoded.scope) this.tokenScope = decoded.scope
583
+
584
+ const expirationBuffer = 5
585
+ const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
586
+
587
+ if (isExpired) {
588
+ log('token is expired - refreshing ...')
589
+ const refreshToken = this.token.refresh_token
590
+ if (!refreshToken) {
591
+ log('Error: No refresh token available for expired token')
592
+ this.isAuthReady = true
593
+ this.notify()
594
+ return
595
+ }
596
+ try {
597
+ const newToken = await this.exchangeToken(refreshToken, true)
598
+ await this.fetchUser(newToken?.access_token || '')
599
+ } catch (error) {
600
+ log('Failed to refresh token in processNewToken:', error)
601
+ if (this.isNetworkError(error)) {
602
+ log('Network issue - continuing with expired token until online')
603
+ await this.fetchUser(this.token.access_token)
604
+ } else {
605
+ this.isAuthReady = true
606
+ this.notify()
607
+ }
608
+ }
609
+ } else {
610
+ await this.fetchUser(this.token.access_token)
611
+ }
612
+ } catch (error) {
613
+ log('Error processing token:', error)
614
+ this.isAuthReady = true
615
+ this.notify()
616
+ }
617
+ }
618
+
619
+ private async fetchUser(accessToken: string): Promise<void> {
620
+ log('fetching user')
621
+ try {
622
+ const endpoints = await this.getActivePdsEndpoints()
623
+ const response = await fetch(endpoints.userinfo_endpoint, {
624
+ method: 'GET',
625
+ headers: { 'Authorization': `Bearer ${accessToken}` }
626
+ })
627
+
628
+ if (!response.ok) {
629
+ throw new Error(`Failed to fetch user info: ${response.status}`)
630
+ }
631
+
632
+ const user = await response.json()
633
+
634
+ if (user.error) {
635
+ log('error fetching user', user.error)
636
+ throw new Error(`User info error: ${user.error}`)
637
+ }
638
+
639
+ if (this.token?.refresh_token) {
640
+ await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, this.token.refresh_token)
641
+ }
642
+
643
+ await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user))
644
+ log('Cached user info in storage')
645
+
646
+ this.user = user
647
+ this.isSignedIn = true
648
+ this.isAuthReady = true
649
+
650
+ if (this.freshSignIn) {
651
+ this.freshSignIn = false
652
+ this.broadcastSignIn()
653
+ } else {
654
+ this.broadcastTokenRefresh()
655
+ }
656
+
657
+ this.notify()
658
+ } catch (error) {
659
+ log('Failed to fetch user info:', error)
660
+ this.isAuthReady = true
661
+ this.notify()
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Exchange an auth code or refresh token for an access token.
667
+ * Handles mutex (one in-flight refresh), token validation, and
668
+ * triggers processNewToken on success.
669
+ */
670
+ private async exchangeToken(codeOrRefreshToken: string, isRefreshToken: boolean): Promise<Token | null> {
671
+ if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
672
+ const errorMsg = isRefreshToken ? 'Refresh token is empty or undefined' : 'Authorization code is empty or undefined'
673
+ log('Error:', errorMsg)
674
+ throw new Error(errorMsg)
675
+ }
676
+
677
+ if (isRefreshToken && this.refreshPromise) {
678
+ log('Reusing in-flight refresh token request')
679
+ return this.refreshPromise
680
+ }
681
+
682
+ if (!isRefreshToken && this.codeExchangePromise) {
683
+ log('Reusing in-flight code exchange request')
684
+ return this.codeExchangePromise
685
+ }
686
+
687
+ const tokenPromise = (async (): Promise<Token | null> => {
688
+ try {
689
+ if (!this.isOnline) {
690
+ log('Network is offline, marking refresh as pending')
691
+ this.pendingRefresh = true
692
+ throw new Error('Network offline - refresh will be retried when online')
693
+ }
694
+
695
+ const endpoints = await this.getActivePdsEndpoints()
696
+ let requestBody: any
697
+
698
+ if (isRefreshToken) {
699
+ requestBody = {
700
+ grant_type: 'refresh_token',
701
+ refresh_token: codeOrRefreshToken
702
+ }
703
+ if (this.config.projectId) {
704
+ requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname)
705
+ }
706
+ } else {
707
+ requestBody = {
708
+ grant_type: 'authorization_code',
709
+ code: codeOrRefreshToken
710
+ }
711
+
712
+ const storedRedirectUri = await this.storage.get(STORAGE_KEYS.REDIRECT_URI)
713
+ if (storedRedirectUri) {
714
+ requestBody.redirect_uri = storedRedirectUri
715
+ log('Including redirect_uri in token exchange:', storedRedirectUri)
716
+ } else {
717
+ log('Warning: No redirect_uri found in storage for token exchange')
718
+ }
719
+
720
+ // PKCE: include code_verifier from the authorization request
721
+ const codeVerifier = await this.storage.get(STORAGE_KEYS.CODE_VERIFIER)
722
+ if (codeVerifier) {
723
+ requestBody.code_verifier = codeVerifier
724
+ }
725
+
726
+ if (this.config.projectId) {
727
+ requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname)
728
+ }
729
+ }
730
+
731
+ log('Token exchange request body:', {
732
+ ...requestBody,
733
+ ...(isRefreshToken ? { refresh_token: '[REDACTED]' } : { code: '[REDACTED]' }),
734
+ ...(requestBody.code_verifier ? { code_verifier: '[REDACTED]' } : {}),
735
+ })
736
+
737
+ const token = await fetch(endpoints.token_endpoint, {
738
+ method: 'POST',
739
+ headers: { 'Content-Type': 'application/json' },
740
+ body: JSON.stringify(requestBody)
741
+ })
742
+ .then(response => response.json())
743
+ .catch(error => {
744
+ log('Network error fetching token:', error)
745
+ if (!this.isOnline) {
746
+ this.pendingRefresh = true
747
+ throw new Error('Network offline - refresh will be retried when online')
748
+ }
749
+ throw new Error('Network error during token refresh')
750
+ })
751
+
752
+ if (token.access_token) {
753
+ try {
754
+ const decoded = jwtDecode<{ typ?: string }>(token.access_token)
755
+ if (decoded.typ === 'refresh') {
756
+ log('Error: received refresh token as access token')
757
+ throw new Error('Invalid token: received refresh token instead of access token')
758
+ }
759
+ } catch (decodeError) {
760
+ if ((decodeError as Error).message.includes('Invalid token')) {
761
+ throw decodeError
762
+ }
763
+ log('Warning: could not decode access token for type check:', decodeError)
764
+ }
765
+ }
766
+
767
+ if (token.error) {
768
+ log('error fetching token', token.error)
769
+
770
+ if (typeof token.error === 'string' && (token.error.includes('network') || token.error.includes('timeout'))) {
771
+ this.pendingRefresh = true
772
+ throw new Error('Network issue - refresh will be retried when online')
773
+ }
774
+
775
+ await this.clearStoredAuth()
776
+ this.resetAuthState()
777
+ this.notify()
778
+ throw new Error(`Token refresh failed: ${token.error}`)
779
+ } else {
780
+ this.token = token
781
+ this.pendingRefresh = false
782
+
783
+ if (token.refresh_token) {
784
+ await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
785
+ log('Updated refresh token in storage')
786
+ }
787
+
788
+ if (!isRefreshToken) {
789
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
790
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
791
+ log('Cleaned up redirect_uri and code_verifier from storage after successful exchange')
792
+ }
793
+
794
+ this.reportConnection(token.access_token).catch(() => {})
795
+
796
+ await this.processNewToken()
797
+ }
798
+
799
+ return token
800
+ } catch (error) {
801
+ log('Token refresh error:', error)
802
+
803
+ if (!this.isNetworkError(error)) {
804
+ await this.clearStoredAuth()
805
+ this.resetAuthState()
806
+ this.notify()
807
+ }
808
+
809
+ throw error
810
+ }
811
+ })()
812
+
813
+ if (isRefreshToken) {
814
+ this.refreshPromise = tokenPromise
815
+ tokenPromise.finally(() => {
816
+ if (this.refreshPromise === tokenPromise) {
817
+ this.refreshPromise = null
818
+ log('Cleared refresh promise reference')
819
+ }
820
+ })
821
+ } else {
822
+ this.codeExchangePromise = tokenPromise
823
+ tokenPromise.finally(() => {
824
+ if (this.codeExchangePromise === tokenPromise) {
825
+ this.codeExchangePromise = null
826
+ log('Cleared code exchange promise reference')
827
+ }
828
+ })
829
+ }
830
+
831
+ return tokenPromise
832
+ }
833
+
834
+ private resetAuthState(): void {
835
+ this.user = null
836
+ this.isSignedIn = false
837
+ this.token = null
838
+ this.did = null
839
+ this.tokenScope = null
840
+ this.isAuthReady = true
841
+ }
842
+
843
+ private async clearStoredAuth(): Promise<void> {
844
+ await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN)
845
+ await this.storage.remove(STORAGE_KEYS.USER_INFO)
846
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
847
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
848
+ await this.storage.remove(STORAGE_KEYS.SERVER_URL)
849
+ await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS)
850
+ }
851
+
852
+ private isNetworkError(error: unknown): boolean {
853
+ if (error instanceof Error) {
854
+ return error.message.includes('offline') || error.message.includes('Network')
855
+ }
856
+ return false
857
+ }
858
+ }