@basictech/react 0.8.0-beta.3 → 0.9.0-beta.0

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.
@@ -1,882 +0,0 @@
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(async (error) => {
227
- log('Error fetching refresh token:', error)
228
- if (this.isNetworkError(error)) {
229
- await this.restoreCachedUser()
230
- }
231
- })
232
- } else {
233
- const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO)
234
- if (cachedUserInfo) {
235
- try {
236
- this.user = JSON.parse(cachedUserInfo)
237
- this.isSignedIn = true
238
- log('Loaded cached user info for offline mode')
239
- } catch (error) {
240
- log('Error parsing cached user info:', error)
241
- }
242
- }
243
- this.isAuthReady = true
244
- this.notify()
245
- }
246
- }
247
- } catch (e) {
248
- log('error getting token', e)
249
- }
250
- }
251
-
252
- /**
253
- * Get a valid access token string. Refreshes proactively (5s buffer)
254
- * or on demand (forceRefresh). Mutex prevents concurrent refreshes.
255
- */
256
- async getToken(options?: GetTokenOptions): Promise<string> {
257
- log('getting token...')
258
-
259
- if (!this.token) {
260
- const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
261
- if (refreshToken) {
262
- log('No token in memory, attempting to refresh from storage')
263
-
264
- if (this.refreshPromise) {
265
- log('Token refresh already in progress, waiting...')
266
- try {
267
- const newToken = await this.refreshPromise
268
- if (newToken?.access_token) {
269
- return newToken.access_token
270
- }
271
- } catch (error) {
272
- log('In-flight refresh failed:', error)
273
- throw error
274
- }
275
- }
276
-
277
- try {
278
- const newToken = await this.exchangeToken(refreshToken, true)
279
- if (newToken?.access_token) {
280
- return newToken.access_token
281
- }
282
- } catch (error) {
283
- log('Failed to refresh token from storage:', error)
284
- if (this.isNetworkError(error)) {
285
- throw new Error('Network offline - authentication will be retried when online')
286
- }
287
- throw new Error('Authentication expired. Please sign in again.')
288
- }
289
- }
290
- log('no token found')
291
- throw new Error('no token found')
292
- }
293
-
294
- const decoded = jwtDecode(this.token.access_token)
295
- const expirationBuffer = 5
296
- const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
297
- const shouldRefresh = isExpired || options?.forceRefresh === true
298
-
299
- if (shouldRefresh) {
300
- log(options?.forceRefresh ? 'force refreshing token...' : 'token is expired - refreshing ...')
301
-
302
- if (this.refreshPromise) {
303
- log('Token refresh already in progress, waiting...')
304
- try {
305
- const newToken = await this.refreshPromise
306
- if (!newToken?.access_token) throw new Error('Token refresh returned empty access token')
307
- return newToken.access_token
308
- } catch (error) {
309
- log('In-flight refresh failed:', error)
310
- if (this.isNetworkError(error)) {
311
- log('Network issue - using expired token until network is restored')
312
- return this.token.access_token
313
- }
314
- throw error
315
- }
316
- }
317
-
318
- const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
319
- if (refreshToken) {
320
- try {
321
- const newToken = await this.exchangeToken(refreshToken, true)
322
- if (!newToken?.access_token) throw new Error('Token refresh returned empty access token')
323
- return newToken.access_token
324
- } catch (error) {
325
- log('Failed to refresh expired token:', error)
326
- if (this.isNetworkError(error)) {
327
- log('Network issue - using expired token until network is restored')
328
- return this.token.access_token
329
- }
330
- throw new Error('Authentication expired. Please sign in again.')
331
- }
332
- } else {
333
- throw new Error('no refresh token available')
334
- }
335
- }
336
-
337
- if (!this.token.access_token) throw new Error('Token exists but access_token is empty')
338
- return this.token.access_token
339
- }
340
-
341
- async getSignInUrl(redirectUri?: string, endpoints?: PdsEndpoints): Promise<string> {
342
- log('getting sign in link...')
343
-
344
- if (!this.config.projectId) {
345
- throw new Error('Project ID is required to generate sign-in link')
346
- }
347
-
348
- const pdsEndpoints = endpoints || this.defaultPdsEndpoints()
349
- await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints))
350
-
351
- const randomState = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
352
- await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState)
353
-
354
- const redirectUrl = redirectUri || window.location.href
355
- if (!redirectUrl || (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://'))) {
356
- throw new Error('Invalid redirect URI provided')
357
- }
358
-
359
- await this.storage.set(STORAGE_KEYS.REDIRECT_URI, redirectUrl)
360
- log('Stored redirect_uri for token exchange:', redirectUrl)
361
-
362
- // PKCE: generate code_verifier and code_challenge (RFC 7636)
363
- const codeVerifier = generateCodeVerifier()
364
- const { challenge: codeChallenge, method: challengeMethod } = await generateCodeChallenge(codeVerifier)
365
- await this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier)
366
-
367
- let baseUrl = pdsEndpoints.authorization_endpoint
368
- baseUrl += `?client_id=${encodeURIComponent(normalizeClientId(this.config.projectId, this.adminHostname))}`
369
- baseUrl += `&redirect_uri=${encodeURIComponent(redirectUrl)}`
370
- baseUrl += `&response_type=code`
371
- baseUrl += `&scope=${encodeURIComponent(this.config.scopes)}`
372
- baseUrl += `&state=${randomState}`
373
- baseUrl += `&code_challenge=${encodeURIComponent(codeChallenge)}`
374
- baseUrl += `&code_challenge_method=${challengeMethod}`
375
-
376
- log('Generated sign-in link successfully with scopes:', this.config.scopes)
377
- return baseUrl
378
- }
379
-
380
- async signIn(redirectUri?: string): Promise<void> {
381
- log('signing in...')
382
-
383
- if (!this.config.projectId) {
384
- log('Error: project_id is required for sign-in')
385
- throw new Error('Project ID is required for authentication')
386
- }
387
-
388
- const signInLink = await this.getSignInUrl(redirectUri)
389
- log('Generated sign-in link:', signInLink)
390
-
391
- try { new URL(signInLink) } catch {
392
- log('Error: Invalid sign-in link generated')
393
- throw new Error('Failed to generate valid sign-in URL')
394
- }
395
-
396
- window.location.href = signInLink
397
- }
398
-
399
- async signInWithHandle(handle: string): Promise<void> {
400
- log('signing in with handle:', handle)
401
-
402
- if (!this.config.projectId) {
403
- throw new Error('Project ID is required for authentication')
404
- }
405
-
406
- const resolved = await resolveHandle(handle)
407
- log('Resolved handle to PDS:', resolved.pdsUrl)
408
-
409
- const endpoints: PdsEndpoints = {
410
- pds_url: resolved.pdsUrl,
411
- authorization_endpoint: resolved.authorization_endpoint,
412
- token_endpoint: resolved.token_endpoint,
413
- userinfo_endpoint: resolved.userinfo_endpoint
414
- }
415
-
416
- const signInLink = await this.getSignInUrl(undefined, endpoints)
417
- log('Generated federated sign-in link:', signInLink)
418
-
419
- try { new URL(signInLink) } catch {
420
- throw new Error('Failed to generate valid sign-in URL')
421
- }
422
-
423
- window.location.href = signInLink
424
- }
425
-
426
- async signInWithCode(code: string, state?: string): Promise<AuthResult> {
427
- try {
428
- log('signInWithCode called with code:', code)
429
-
430
- if (!code || typeof code !== 'string') {
431
- return { success: false, error: 'Invalid authorization code' }
432
- }
433
-
434
- if (state) {
435
- const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE)
436
- if (storedState && storedState !== state) {
437
- log('State parameter mismatch:', { provided: state, stored: storedState })
438
- return { success: false, error: 'State parameter mismatch' }
439
- }
440
- }
441
-
442
- await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
443
- cleanOAuthParamsFromUrl()
444
-
445
- this.freshSignIn = true
446
- const token = await this.exchangeToken(code, false)
447
- if (token) {
448
- log('signInWithCode successful')
449
- return { success: true }
450
- } else {
451
- return { success: false, error: 'Failed to exchange code for token' }
452
- }
453
- } catch (error) {
454
- log('signInWithCode error:', error)
455
- return {
456
- success: false,
457
- error: (error as Error).message || 'Authentication failed'
458
- }
459
- }
460
- }
461
-
462
- /**
463
- * Clear auth state and storage. Does NOT handle sync/DB cleanup —
464
- * the UI layer (BasicProvider) wraps this to add sync teardown.
465
- */
466
- async signOut(): Promise<void> {
467
- log('signing out!')
468
- this.resetAuthState()
469
-
470
- await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
471
- await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT)
472
- await this.clearStoredAuth()
473
-
474
- this.broadcastSignOut()
475
- this.notify()
476
- }
477
-
478
- hasScope(scope: string): boolean {
479
- if (!this.tokenScope) return false
480
- return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope)
481
- }
482
-
483
- /**
484
- * Returns scopes that were requested but not granted in the current token.
485
- * Useful after login or when a 403 is returned.
486
- */
487
- missingScopes(): string[] {
488
- const requested = this.requestedScopes.split(/[\s,]+/).filter(Boolean)
489
- if (!this.tokenScope) return requested
490
- const granted = new Set(this.tokenScope.split(/[\s,]+/).filter(Boolean))
491
- return requested.filter(s => !granted.has(s))
492
- }
493
-
494
- /**
495
- * Register online/offline and visibility handlers that retry pending
496
- * refreshes and proactively refresh tokens when the app resumes from
497
- * background (critical for PWAs and mobile browsers where timers are
498
- * frozen while backgrounded).
499
- * Returns a cleanup function for useEffect teardown.
500
- */
501
- setupNetworkListeners(): () => void {
502
- const handleOnline = async () => {
503
- log('Network came back online')
504
- this.isOnline = true
505
- if (this.pendingRefresh && this.token) {
506
- log('Retrying pending token refresh')
507
- this.pendingRefresh = false
508
- const refreshToken = this.token.refresh_token || await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
509
- if (refreshToken) {
510
- this.exchangeToken(refreshToken, true).catch(error => {
511
- log('Retry refresh failed:', error)
512
- })
513
- }
514
- }
515
- }
516
-
517
- const handleOffline = () => {
518
- log('Network went offline')
519
- this.isOnline = false
520
- }
521
-
522
- const handleVisibilityChange = () => {
523
- if (document.visibilityState === 'visible' && this.isSignedIn) {
524
- log('App became visible - checking token freshness')
525
- this.getToken().catch(err => {
526
- log('Token refresh on visibility resume failed:', err)
527
- })
528
- }
529
- }
530
-
531
- window.addEventListener('online', handleOnline)
532
- window.addEventListener('offline', handleOffline)
533
-
534
- if (typeof document !== 'undefined') {
535
- document.addEventListener('visibilitychange', handleVisibilityChange)
536
- }
537
-
538
- return () => {
539
- window.removeEventListener('online', handleOnline)
540
- window.removeEventListener('offline', handleOffline)
541
- if (typeof document !== 'undefined') {
542
- document.removeEventListener('visibilitychange', handleVisibilityChange)
543
- }
544
- }
545
- }
546
-
547
- // ------------------------------------------------------------------
548
- // Private
549
- // ------------------------------------------------------------------
550
-
551
- private get adminHostname(): string {
552
- try { return new URL(this.config.adminUrl).hostname }
553
- catch { return 'api.basic.tech' }
554
- }
555
-
556
- private defaultPdsEndpoints(): PdsEndpoints {
557
- return {
558
- pds_url: this.config.pdsUrl,
559
- authorization_endpoint: `${this.config.pdsUrl}/auth/authorize`,
560
- token_endpoint: `${this.config.pdsUrl}/auth/token`,
561
- userinfo_endpoint: `${this.config.pdsUrl}/auth/userinfo`
562
- }
563
- }
564
-
565
- private async getActivePdsEndpoints(): Promise<PdsEndpoints> {
566
- const stored = await this.storage.get(STORAGE_KEYS.PDS_ENDPOINTS)
567
- if (stored) {
568
- try { return JSON.parse(stored) as PdsEndpoints } catch { /* fall through */ }
569
- }
570
- return this.defaultPdsEndpoints()
571
- }
572
-
573
- private async reportConnection(accessToken: string): Promise<void> {
574
- if (!this.config.projectId || !this.config.adminUrl) return
575
- const lastReport = await this.storage.get(STORAGE_KEYS.LAST_CONNECT_REPORT)
576
- if (lastReport) {
577
- const elapsed = Date.now() - parseInt(lastReport, 10)
578
- if (elapsed < 24 * 60 * 60 * 1000) return
579
- }
580
- try {
581
- await fetch(`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`, {
582
- method: 'POST',
583
- headers: { 'Content-Type': 'application/json' },
584
- body: JSON.stringify({ token: accessToken })
585
- })
586
- await this.storage.set(STORAGE_KEYS.LAST_CONNECT_REPORT, Date.now().toString())
587
- log('Reported connection to admin server')
588
- } catch (err) {
589
- log('Failed to report connection (non-blocking):', err)
590
- }
591
- }
592
-
593
- /**
594
- * After a new token is stored, decode JWT claims and fetch user info.
595
- */
596
- private async processNewToken(): Promise<void> {
597
- if (!this.token) {
598
- this.isAuthReady = true
599
- this.notify()
600
- return
601
- }
602
-
603
- try {
604
- const decoded = jwtDecode<{ sub?: string; scope?: string; typ?: string; exp?: number }>(this.token.access_token)
605
-
606
- if (decoded.sub) this.did = decoded.sub
607
- if (decoded.scope) this.tokenScope = decoded.scope
608
-
609
- await this.fetchUser(this.token.access_token)
610
- } catch (error) {
611
- log('Error processing token:', error)
612
- this.isAuthReady = true
613
- this.notify()
614
- }
615
- }
616
-
617
- private async restoreCachedUser(): Promise<void> {
618
- const cached = await this.storage.get(STORAGE_KEYS.USER_INFO)
619
- if (cached) {
620
- try {
621
- this.user = JSON.parse(cached)
622
- this.isSignedIn = true
623
- log('Restored cached user info for offline mode')
624
- } catch { /* corrupted cache, ignore */ }
625
- }
626
- this.isAuthReady = true
627
- this.notify()
628
- }
629
-
630
- private async fetchUser(accessToken: string): Promise<void> {
631
- log('fetching user')
632
- try {
633
- const endpoints = await this.getActivePdsEndpoints()
634
- const response = await fetch(endpoints.userinfo_endpoint, {
635
- method: 'GET',
636
- headers: { 'Authorization': `Bearer ${accessToken}` }
637
- })
638
-
639
- if (!response.ok) {
640
- throw new Error(`Failed to fetch user info: ${response.status}`)
641
- }
642
-
643
- const user = await response.json()
644
-
645
- if (user.error) {
646
- log('error fetching user', user.error)
647
- throw new Error(`User info error: ${user.error}`)
648
- }
649
-
650
- if (this.token?.refresh_token) {
651
- await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, this.token.refresh_token)
652
- }
653
-
654
- await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user))
655
- log('Cached user info in storage')
656
-
657
- this.user = user
658
- this.isSignedIn = true
659
- this.isAuthReady = true
660
-
661
- if (this.freshSignIn) {
662
- this.freshSignIn = false
663
- this.broadcastSignIn()
664
- } else {
665
- this.broadcastTokenRefresh()
666
- }
667
-
668
- this.notify()
669
- } catch (error) {
670
- log('Failed to fetch user info:', error)
671
- if (this.isNetworkError(error)) {
672
- await this.restoreCachedUser()
673
- } else {
674
- this.isAuthReady = true
675
- this.notify()
676
- }
677
- }
678
- }
679
-
680
- /**
681
- * Exchange an auth code or refresh token for an access token.
682
- * Handles mutex (one in-flight refresh), token validation, and
683
- * triggers processNewToken on success.
684
- */
685
- private async exchangeToken(codeOrRefreshToken: string, isRefreshToken: boolean): Promise<Token | null> {
686
- if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
687
- const errorMsg = isRefreshToken ? 'Refresh token is empty or undefined' : 'Authorization code is empty or undefined'
688
- log('Error:', errorMsg)
689
- throw new Error(errorMsg)
690
- }
691
-
692
- if (isRefreshToken && this.refreshPromise) {
693
- log('Reusing in-flight refresh token request')
694
- return this.refreshPromise
695
- }
696
-
697
- if (!isRefreshToken && this.codeExchangePromise) {
698
- log('Reusing in-flight code exchange request')
699
- return this.codeExchangePromise
700
- }
701
-
702
- const tokenPromise = (async (): Promise<Token | null> => {
703
- try {
704
- if (!this.isOnline) {
705
- log('Network is offline, marking refresh as pending')
706
- this.pendingRefresh = true
707
- throw new Error('Network offline - refresh will be retried when online')
708
- }
709
-
710
- const endpoints = await this.getActivePdsEndpoints()
711
- let requestBody: any
712
-
713
- if (isRefreshToken) {
714
- requestBody = {
715
- grant_type: 'refresh_token',
716
- refresh_token: codeOrRefreshToken
717
- }
718
- if (this.config.projectId) {
719
- requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname)
720
- }
721
- } else {
722
- requestBody = {
723
- grant_type: 'authorization_code',
724
- code: codeOrRefreshToken
725
- }
726
-
727
- const storedRedirectUri = await this.storage.get(STORAGE_KEYS.REDIRECT_URI)
728
- if (storedRedirectUri) {
729
- requestBody.redirect_uri = storedRedirectUri
730
- log('Including redirect_uri in token exchange:', storedRedirectUri)
731
- } else {
732
- log('Warning: No redirect_uri found in storage for token exchange')
733
- }
734
-
735
- // PKCE: include code_verifier from the authorization request
736
- const codeVerifier = await this.storage.get(STORAGE_KEYS.CODE_VERIFIER)
737
- if (codeVerifier) {
738
- requestBody.code_verifier = codeVerifier
739
- }
740
-
741
- if (this.config.projectId) {
742
- requestBody.client_id = normalizeClientId(this.config.projectId, this.adminHostname)
743
- }
744
- }
745
-
746
- log('Token exchange request body:', {
747
- ...requestBody,
748
- ...(isRefreshToken ? { refresh_token: '[REDACTED]' } : { code: '[REDACTED]' }),
749
- ...(requestBody.code_verifier ? { code_verifier: '[REDACTED]' } : {}),
750
- })
751
-
752
- const token = await fetch(endpoints.token_endpoint, {
753
- method: 'POST',
754
- headers: { 'Content-Type': 'application/json' },
755
- body: JSON.stringify(requestBody)
756
- })
757
- .then(response => response.json())
758
- .catch(error => {
759
- log('Network error fetching token:', error)
760
- if (!this.isOnline) {
761
- this.pendingRefresh = true
762
- throw new Error('Network offline - refresh will be retried when online')
763
- }
764
- throw new Error('Network error during token refresh')
765
- })
766
-
767
- if (token.access_token) {
768
- try {
769
- const decoded = jwtDecode<{ typ?: string }>(token.access_token)
770
- if (decoded.typ === 'refresh') {
771
- log('Error: received refresh token as access token')
772
- throw new Error('Invalid token: received refresh token instead of access token')
773
- }
774
- } catch (decodeError) {
775
- if ((decodeError as Error).message.includes('Invalid token')) {
776
- throw decodeError
777
- }
778
- log('Warning: could not decode access token for type check:', decodeError)
779
- }
780
- }
781
-
782
- if (token.error) {
783
- log('error fetching token', token.error)
784
-
785
- if (typeof token.error === 'string' && (token.error.includes('network') || token.error.includes('timeout'))) {
786
- this.pendingRefresh = true
787
- throw new Error('Network issue - refresh will be retried when online')
788
- }
789
-
790
- // Only clear stored auth on definitive OAuth rejection.
791
- // Transient server errors (500, 503, etc.) should NOT wipe
792
- // the refresh token — the user can retry later.
793
- const definitiveErrors = ['invalid_grant', 'invalid_client', 'unauthorized_client']
794
- if (typeof token.error === 'string' && definitiveErrors.includes(token.error)) {
795
- await this.clearStoredAuth()
796
- this.resetAuthState()
797
- this.notify()
798
- }
799
- throw new Error(`Token refresh failed: ${token.error}`)
800
- } else {
801
- this.token = token
802
- this.pendingRefresh = false
803
-
804
- if (token.refresh_token) {
805
- await this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, token.refresh_token)
806
- log('Updated refresh token in storage')
807
- }
808
-
809
- if (!isRefreshToken) {
810
- await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
811
- await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
812
- log('Cleaned up redirect_uri and code_verifier from storage after successful exchange')
813
- }
814
-
815
- this.reportConnection(token.access_token).catch(() => {})
816
-
817
- await this.processNewToken()
818
- }
819
-
820
- return token
821
- } catch (error) {
822
- log('Token refresh error:', error)
823
-
824
- const msg = error instanceof Error ? error.message : ''
825
- const alreadyHandled = msg.startsWith('Token refresh failed:')
826
- if (!alreadyHandled && !this.isNetworkError(error)) {
827
- await this.clearStoredAuth()
828
- this.resetAuthState()
829
- this.notify()
830
- }
831
-
832
- throw error
833
- }
834
- })()
835
-
836
- if (isRefreshToken) {
837
- this.refreshPromise = tokenPromise
838
- tokenPromise.finally(() => {
839
- if (this.refreshPromise === tokenPromise) {
840
- this.refreshPromise = null
841
- log('Cleared refresh promise reference')
842
- }
843
- })
844
- } else {
845
- this.codeExchangePromise = tokenPromise
846
- tokenPromise.finally(() => {
847
- if (this.codeExchangePromise === tokenPromise) {
848
- this.codeExchangePromise = null
849
- log('Cleared code exchange promise reference')
850
- }
851
- })
852
- }
853
-
854
- return tokenPromise
855
- }
856
-
857
- private resetAuthState(): void {
858
- this.user = null
859
- this.isSignedIn = false
860
- this.token = null
861
- this.did = null
862
- this.tokenScope = null
863
- this.isAuthReady = true
864
- }
865
-
866
- private async clearStoredAuth(): Promise<void> {
867
- await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN)
868
- await this.storage.remove(STORAGE_KEYS.USER_INFO)
869
- await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
870
- await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
871
- await this.storage.remove(STORAGE_KEYS.SERVER_URL)
872
- await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS)
873
- }
874
-
875
- private isNetworkError(error: unknown): boolean {
876
- if (error instanceof TypeError) return true
877
- if (error instanceof Error) {
878
- return error.message.includes('offline') || error.message.includes('Network')
879
- }
880
- return false
881
- }
882
- }