@basictech/react 0.8.0-beta.3 → 0.8.0-beta.4

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.
@@ -5,70 +5,137 @@ import { resolveHandle } from '../../utils/resolveDid'
5
5
  import { cleanOAuthParamsFromUrl } from '../../utils/network'
6
6
  import { log } from '../../config'
7
7
 
8
+ const DEFINITIVE_TOKEN_ERRORS = new Set([
9
+ 'invalid_grant',
10
+ 'invalid_client',
11
+ 'unauthorized_client',
12
+ ])
13
+ const USER_RECOVERY_RETRY_COOLDOWN_MS = 30_000
14
+ const SESSION_RECONCILE_THROTTLE_MS = 5_000
15
+
16
+ class DefinitiveAuthError extends Error {
17
+ readonly code: string
18
+
19
+ constructor(code: string) {
20
+ super(`Definitive auth failure: ${code}`)
21
+ this.name = 'DefinitiveAuthError'
22
+ this.code = code
23
+ }
24
+ }
25
+
8
26
  // --- PKCE helpers (RFC 7636) ---
9
27
 
10
28
  function generateCodeVerifier(): string {
11
- const array = new Uint8Array(32)
12
- crypto.getRandomValues(array)
13
- return base64UrlEncode(array)
29
+ const array = new Uint8Array(32)
30
+ crypto.getRandomValues(array)
31
+ return base64UrlEncode(array)
14
32
  }
15
33
 
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' }
34
+ async function generateCodeChallenge(
35
+ verifier: string,
36
+ ): Promise<{ challenge: string; method: 'S256' | 'plain' }> {
37
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
38
+ log(
39
+ 'crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge',
40
+ )
41
+ return { challenge: verifier, method: 'plain' }
42
+ }
43
+ const encoder = new TextEncoder()
44
+ const data = encoder.encode(verifier)
45
+ const digest = await crypto.subtle.digest('SHA-256', data)
46
+ return { challenge: base64UrlEncode(new Uint8Array(digest)), method: 'S256' }
25
47
  }
26
48
 
27
49
  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(/=+$/, '')
50
+ let str = ''
51
+ for (let i = 0; i < buffer.length; i++) {
52
+ str += String.fromCharCode(buffer[i]!)
53
+ }
54
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
33
55
  }
34
56
 
35
57
  export type Token = {
36
- access_token: string
37
- token_type: string
38
- expires_in: number
39
- refresh_token: string
58
+ access_token: string
59
+ token_type: string
60
+ expires_in: number
61
+ refresh_token: string
40
62
  }
41
63
 
42
64
  export type User = {
43
- sub?: string
44
- name?: string
45
- email?: string
46
- picture?: string
65
+ sub?: string
66
+ name?: string
67
+ email?: string
68
+ picture?: string
47
69
  }
48
70
 
71
+ /**
72
+ * High-level auth lifecycle state.
73
+ *
74
+ * - `bootstrapping` — SDK is initializing; not yet determined if a session exists.
75
+ * - `authenticated` — User has a valid access token and active session.
76
+ * - `recovering` — A session likely exists (refresh token / cached user) but
77
+ * the SDK hasn't confirmed it yet (e.g. offline, mid-refresh).
78
+ * - `reauth_required` — The session is definitively invalid (revoked, expired
79
+ * refresh token, etc.). The user must sign in again.
80
+ * NOTE: `isSignedIn` remains `true` in this state so the UI
81
+ * can display user info while prompting re-authentication.
82
+ * Use `authStatus === 'reauth_required'` to distinguish
83
+ * this from a healthy signed-in state.
84
+ * - `signed_out` — No session. User is not authenticated.
85
+ *
86
+ * TODO: revisit naming and ergonomics — consider adding a `needsReauth` or
87
+ * `shouldPromptSignIn` convenience getter so consumers don't need to inspect
88
+ * the raw status to decide between "sign in" vs "sign out" UI.
89
+ */
90
+ export type AuthStatus =
91
+ | 'bootstrapping'
92
+ | 'authenticated'
93
+ | 'recovering'
94
+ | 'reauth_required'
95
+ | 'signed_out'
96
+
49
97
  export type AuthResult = {
50
- success: boolean
51
- error?: string
52
- code?: string
98
+ success: boolean
99
+ error?: string
100
+ code?: string
53
101
  }
54
102
 
55
103
  export type GetTokenOptions = {
56
- forceRefresh?: boolean
104
+ forceRefresh?: boolean
57
105
  }
58
106
 
59
107
  export type PdsEndpoints = {
60
- pds_url: string
61
- authorization_endpoint: string
62
- token_endpoint: string
63
- userinfo_endpoint: string
108
+ pds_url: string
109
+ authorization_endpoint: string
110
+ token_endpoint: string
111
+ userinfo_endpoint: string
64
112
  }
65
113
 
66
114
  export type AuthManagerConfig = {
67
- projectId: string | undefined
68
- scopes: string
69
- pdsUrl: string
70
- adminUrl: string
71
- debug: boolean
115
+ projectId: string | undefined
116
+ scopes: string
117
+ pdsUrl: string
118
+ adminUrl: string
119
+ debug: boolean
120
+ }
121
+
122
+ type JwtClaims = {
123
+ sub?: string
124
+ scope?: string
125
+ typ?: string
126
+ exp?: number
127
+ }
128
+
129
+ type CurrentSessionInfo = {
130
+ active: boolean
131
+ reauth_required?: boolean
132
+ session_id?: string | null
133
+ client_id?: string
134
+ scope?: string
135
+ account_did?: string
136
+ connection_status?: string
137
+ last_seen_at?: string | null
138
+ error?: string
72
139
  }
73
140
 
74
141
  /**
@@ -79,804 +146,1226 @@ export type AuthManagerConfig = {
79
146
  * re-renders whenever auth state changes.
80
147
  */
81
148
  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')
149
+ // --- Public state (read by the UI layer) ---
150
+ token: Token | null = null
151
+ user: User | null = null
152
+ isSignedIn: boolean = false
153
+ isAuthReady: boolean = false
154
+ authStatus: AuthStatus = 'bootstrapping'
155
+ authErrorCode: string | null = null
156
+ did: string | null = null
157
+ /** Space-separated scopes granted in the current access token */
158
+ tokenScope: string | null = null
159
+ /** Space-separated scopes originally requested in the auth config */
160
+ requestedScopes: string
161
+
162
+ readonly config: AuthManagerConfig
163
+ readonly storage: BasicStorage
164
+
165
+ /** True only during a user-initiated OAuth code exchange (not session restore) */
166
+ private freshSignIn: boolean = false
167
+
168
+ // --- Private ---
169
+ private notify: () => void
170
+ private refreshPromise: Promise<Token | null> | null = null
171
+ private codeExchangePromise: Promise<Token | null> | null = null
172
+ private pendingRefresh: boolean = false
173
+ private isOnline: boolean =
174
+ typeof navigator !== 'undefined' ? navigator.onLine : true
175
+ private channel: BroadcastChannel | null = null
176
+ private nextUserRecoveryAt: number = 0
177
+ private sessionCheckPromise: Promise<void> | null = null
178
+ private lastSessionCheckAt: number = 0
179
+
180
+ constructor(
181
+ config: AuthManagerConfig,
182
+ storage: BasicStorage,
183
+ notify: () => void,
184
+ ) {
185
+ this.config = config
186
+ this.storage = storage
187
+ this.notify = notify
188
+ this.requestedScopes = config.scopes
189
+ this.initCrossTabSync()
190
+ }
191
+
192
+ private initCrossTabSync(): void {
193
+ if (typeof BroadcastChannel === 'undefined') return
194
+
195
+ try {
196
+ this.channel = new BroadcastChannel('basic-auth')
197
+ this.channel.onmessage = (event) => {
198
+ if (event.data?.type === 'token_refreshed') {
199
+ log('Received token refresh from another tab')
200
+ void this.handleExternalTokenRefresh(event.data)
155
201
  }
202
+ if (event.data?.type === 'signed_in') {
203
+ log('Received sign-in from another tab, restoring session')
204
+ void this.restoreStoredSession('cross-tab sign-in')
205
+ }
206
+ if (event.data?.type === 'signed_out') {
207
+ log('Received sign-out from another tab')
208
+ this.resetAuthState('signed_out')
209
+ this.notify()
210
+ // TODO: replace reload with proper cross-tab sync teardown so
211
+ // other tabs can clean up without a full page reload.
212
+ if (typeof window !== 'undefined') {
213
+ window.location.reload()
214
+ }
215
+ }
216
+ if (event.data?.type === 'session_invalidated') {
217
+ log('Received session invalidation from another tab')
218
+ void this.markReauthRequired(event.data.code || 'invalid_grant', {
219
+ broadcast: false,
220
+ })
221
+ }
222
+ }
223
+ } catch {
224
+ log('BroadcastChannel not available for cross-tab sync')
156
225
  }
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' })
226
+ }
227
+
228
+ private broadcastTokenRefresh(): void {
229
+ this.channel?.postMessage({
230
+ type: 'token_refreshed',
231
+ accessToken: this.token?.access_token,
232
+ did: this.did,
233
+ tokenScope: this.tokenScope,
234
+ })
235
+ }
236
+
237
+ private broadcastSignIn(): void {
238
+ this.channel?.postMessage({ type: 'signed_in' })
239
+ }
240
+
241
+ private broadcastSignOut(): void {
242
+ this.channel?.postMessage({ type: 'signed_out' })
243
+ }
244
+
245
+ private broadcastSessionInvalidated(code: string): void {
246
+ this.channel?.postMessage({ type: 'session_invalidated', code })
247
+ }
248
+
249
+ // ------------------------------------------------------------------
250
+ // Public API
251
+ // ------------------------------------------------------------------
252
+
253
+ /**
254
+ * Bootstrap auth: handle OAuth callback (?code=), restore session
255
+ * from refresh token, or load cached user for offline mode.
256
+ */
257
+ async initialize(): Promise<void> {
258
+ this.updateAuthStatus('bootstrapping')
259
+ await this.storage.set(
260
+ STORAGE_KEYS.DEBUG,
261
+ this.config.debug ? 'true' : 'false',
262
+ )
263
+
264
+ const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL)
265
+ if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
266
+ log('PDS URL changed, clearing stored tokens')
267
+ await this.clearStoredAuth()
169
268
  }
269
+ await this.storage.set(STORAGE_KEYS.SERVER_URL, this.config.pdsUrl)
170
270
 
171
- private broadcastSignOut(): void {
172
- this.channel?.postMessage({ type: 'signed_out' })
173
- }
271
+ try {
272
+ const params = new URLSearchParams(window.location.search)
174
273
 
175
- // ------------------------------------------------------------------
176
- // Public API
177
- // ------------------------------------------------------------------
274
+ if (params.has('code')) {
275
+ const code = params.get('code')
276
+ if (!code) {
277
+ this.updateAuthStatus('signed_out')
278
+ this.notify()
279
+ return
280
+ }
178
281
 
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')
282
+ const state = await this.storage.get(STORAGE_KEYS.AUTH_STATE)
283
+ const urlState = params.get('state')
284
+ if (!state || state !== urlState) {
285
+ log('error: auth state does not match')
286
+ this.updateAuthStatus('signed_out')
287
+ this.notify()
288
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
289
+ cleanOAuthParamsFromUrl()
290
+ return
291
+ }
185
292
 
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()
293
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
294
+ cleanOAuthParamsFromUrl()
295
+
296
+ this.freshSignIn = true
297
+ this.exchangeToken(code, false).catch((error) => {
298
+ log('Error fetching token:', error)
299
+ this.freshSignIn = false
300
+ void this.restoreCachedUser({
301
+ hasRecoverableSession: !this.isDefinitiveAuthFailure(error),
302
+ })
303
+ })
304
+ } else {
305
+ await this.restoreStoredSession('initialize')
306
+ }
307
+ } catch (e) {
308
+ log('error getting token', e)
309
+ this.updateAuthStatus('signed_out')
310
+ this.notify()
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Get a valid access token string. Refreshes proactively (5s buffer)
316
+ * or on demand (forceRefresh). Mutex prevents concurrent refreshes.
317
+ */
318
+ async getToken(options?: GetTokenOptions): Promise<string> {
319
+ log('getting token...')
320
+
321
+ if (!this.token) {
322
+ const refreshToken = await this.getRefreshToken()
323
+ if (refreshToken) {
324
+ log('No token in memory, attempting to refresh from storage')
325
+
326
+ if (this.refreshPromise) {
327
+ log('Token refresh already in progress, waiting...')
328
+ try {
329
+ const newToken = await this.refreshPromise
330
+ if (newToken?.access_token) {
331
+ return newToken.access_token
332
+ }
333
+ } catch (error) {
334
+ log('In-flight refresh failed:', error)
335
+ throw error
336
+ }
190
337
  }
191
- await this.storage.set(STORAGE_KEYS.SERVER_URL, this.config.pdsUrl)
192
338
 
193
339
  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)
340
+ const newToken = await this.exchangeToken(refreshToken, true)
341
+ if (newToken?.access_token) {
342
+ return newToken.access_token
343
+ }
344
+ } catch (error) {
345
+ log('Failed to refresh token from storage:', error)
346
+ if (this.isNetworkError(error)) {
347
+ throw new Error(
348
+ 'Network offline - authentication will be retried when online',
349
+ )
350
+ }
351
+ if (!this.isDefinitiveAuthFailure(error)) {
352
+ throw error
353
+ }
354
+ throw new Error('Authentication expired. Please sign in again.')
249
355
  }
356
+ }
357
+ log('no token found')
358
+ throw new Error('no token found')
250
359
  }
251
360
 
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')
361
+ const decoded = jwtDecode<JwtClaims>(this.token.access_token)
362
+ const expirationBuffer = 5
363
+ const isExpired =
364
+ decoded.exp && decoded.exp < Date.now() / 1000 + expirationBuffer
365
+ const shouldRefresh = isExpired || options?.forceRefresh === true
366
+
367
+ if (shouldRefresh) {
368
+ log(
369
+ options?.forceRefresh
370
+ ? 'force refreshing token...'
371
+ : 'token is expired - refreshing ...',
372
+ )
373
+
374
+ if (this.refreshPromise) {
375
+ log('Token refresh already in progress, waiting...')
376
+ try {
377
+ const newToken = await this.refreshPromise
378
+ if (!newToken?.access_token)
379
+ throw new Error('Token refresh returned empty access token')
380
+ return newToken.access_token
381
+ } catch (error) {
382
+ log('In-flight refresh failed:', error)
383
+ if (this.isNetworkError(error)) {
384
+ log('Network issue - using expired token until network is restored')
385
+ return this.token.access_token
386
+ }
387
+ throw error
292
388
  }
389
+ }
293
390
 
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
- }
391
+ const refreshToken = await this.getRefreshToken()
392
+ if (refreshToken) {
393
+ try {
394
+ const newToken = await this.exchangeToken(refreshToken, true)
395
+ if (!newToken?.access_token)
396
+ throw new Error('Token refresh returned empty access token')
397
+ return newToken.access_token
398
+ } catch (error) {
399
+ log('Failed to refresh expired token:', error)
400
+ if (this.isNetworkError(error)) {
401
+ log('Network issue - using expired token until network is restored')
402
+ return this.token.access_token
403
+ }
404
+ if (!this.isDefinitiveAuthFailure(error)) {
405
+ throw error
406
+ }
407
+ throw new Error('Authentication expired. Please sign in again.')
335
408
  }
336
-
337
- if (!this.token.access_token) throw new Error('Token exists but access_token is empty')
338
- return this.token.access_token
409
+ } else {
410
+ throw new Error('no refresh token available')
411
+ }
339
412
  }
340
413
 
341
- async getSignInUrl(redirectUri?: string, endpoints?: PdsEndpoints): Promise<string> {
342
- log('getting sign in link...')
414
+ if (!this.token.access_token)
415
+ throw new Error('Token exists but access_token is empty')
416
+ return this.token.access_token
417
+ }
343
418
 
344
- if (!this.config.projectId) {
345
- throw new Error('Project ID is required to generate sign-in link')
346
- }
419
+ async getSignInUrl(
420
+ redirectUri?: string,
421
+ endpoints?: PdsEndpoints,
422
+ ): Promise<string> {
423
+ log('getting sign in link...')
347
424
 
348
- const pdsEndpoints = endpoints || this.defaultPdsEndpoints()
349
- await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints))
425
+ if (!this.config.projectId) {
426
+ throw new Error('Project ID is required to generate sign-in link')
427
+ }
350
428
 
351
- const randomState = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
352
- await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState)
429
+ const pdsEndpoints = endpoints || this.defaultPdsEndpoints()
430
+ await this.storage.set(
431
+ STORAGE_KEYS.PDS_ENDPOINTS,
432
+ JSON.stringify(pdsEndpoints),
433
+ )
434
+
435
+ const randomState = base64UrlEncode(
436
+ crypto.getRandomValues(new Uint8Array(16)),
437
+ )
438
+ await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState)
439
+
440
+ const redirectUrl = redirectUri || window.location.href
441
+ if (
442
+ !redirectUrl ||
443
+ (!redirectUrl.startsWith('http://') &&
444
+ !redirectUrl.startsWith('https://'))
445
+ ) {
446
+ throw new Error('Invalid redirect URI provided')
447
+ }
353
448
 
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
- }
449
+ await this.storage.set(STORAGE_KEYS.REDIRECT_URI, redirectUrl)
450
+ log('Stored redirect_uri for token exchange:', redirectUrl)
451
+
452
+ // PKCE: generate code_verifier and code_challenge (RFC 7636)
453
+ const codeVerifier = generateCodeVerifier()
454
+ const { challenge: codeChallenge, method: challengeMethod } =
455
+ await generateCodeChallenge(codeVerifier)
456
+ await this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier)
457
+
458
+ let baseUrl = pdsEndpoints.authorization_endpoint
459
+ baseUrl += `?client_id=${encodeURIComponent(normalizeClientId(this.config.projectId, this.adminHostname))}`
460
+ baseUrl += `&redirect_uri=${encodeURIComponent(redirectUrl)}`
461
+ baseUrl += `&response_type=code`
462
+ baseUrl += `&scope=${encodeURIComponent(this.config.scopes)}`
463
+ baseUrl += `&state=${randomState}`
464
+ baseUrl += `&code_challenge=${encodeURIComponent(codeChallenge)}`
465
+ baseUrl += `&code_challenge_method=${challengeMethod}`
466
+
467
+ log('Generated sign-in link successfully with scopes:', this.config.scopes)
468
+ return baseUrl
469
+ }
470
+
471
+ async signIn(redirectUri?: string): Promise<void> {
472
+ log('signing in...')
473
+
474
+ if (!this.config.projectId) {
475
+ log('Error: project_id is required for sign-in')
476
+ throw new Error('Project ID is required for authentication')
477
+ }
358
478
 
359
- await this.storage.set(STORAGE_KEYS.REDIRECT_URI, redirectUrl)
360
- log('Stored redirect_uri for token exchange:', redirectUrl)
479
+ const signInLink = await this.getSignInUrl(redirectUri)
480
+ log('Generated sign-in link:', signInLink)
361
481
 
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)
482
+ try {
483
+ new URL(signInLink)
484
+ } catch {
485
+ log('Error: Invalid sign-in link generated')
486
+ throw new Error('Failed to generate valid sign-in URL')
487
+ }
488
+
489
+ window.location.href = signInLink
490
+ }
366
491
 
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}`
492
+ async signInWithHandle(handle: string): Promise<void> {
493
+ log('signing in with handle:', handle)
375
494
 
376
- log('Generated sign-in link successfully with scopes:', this.config.scopes)
377
- return baseUrl
495
+ if (!this.config.projectId) {
496
+ throw new Error('Project ID is required for authentication')
378
497
  }
379
498
 
380
- async signIn(redirectUri?: string): Promise<void> {
381
- log('signing in...')
499
+ const resolved = await resolveHandle(handle)
500
+ log('Resolved handle to PDS:', resolved.pdsUrl)
382
501
 
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
- }
502
+ const endpoints: PdsEndpoints = {
503
+ pds_url: resolved.pdsUrl,
504
+ authorization_endpoint: resolved.authorization_endpoint,
505
+ token_endpoint: resolved.token_endpoint,
506
+ userinfo_endpoint: resolved.userinfo_endpoint,
507
+ }
387
508
 
388
- const signInLink = await this.getSignInUrl(redirectUri)
389
- log('Generated sign-in link:', signInLink)
509
+ const signInLink = await this.getSignInUrl(undefined, endpoints)
510
+ log('Generated federated sign-in link:', signInLink)
390
511
 
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')
512
+ try {
513
+ new URL(signInLink)
514
+ } catch {
515
+ throw new Error('Failed to generate valid sign-in URL')
516
+ }
517
+
518
+ window.location.href = signInLink
519
+ }
520
+
521
+ async signInWithCode(code: string, state?: string): Promise<AuthResult> {
522
+ try {
523
+ log('signInWithCode called with code:', code)
524
+
525
+ if (!code || typeof code !== 'string') {
526
+ return { success: false, error: 'Invalid authorization code' }
527
+ }
528
+
529
+ if (state) {
530
+ const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE)
531
+ if (storedState && storedState !== state) {
532
+ log('State parameter mismatch:', {
533
+ provided: state,
534
+ stored: storedState,
535
+ })
536
+ return { success: false, error: 'State parameter mismatch' }
394
537
  }
538
+ }
539
+
540
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
541
+ cleanOAuthParamsFromUrl()
542
+
543
+ this.freshSignIn = true
544
+ const token = await this.exchangeToken(code, false)
545
+ if (token) {
546
+ log('signInWithCode successful')
547
+ return { success: true }
548
+ } else {
549
+ return { success: false, error: 'Failed to exchange code for token' }
550
+ }
551
+ } catch (error) {
552
+ log('signInWithCode error:', error)
553
+ this.freshSignIn = false
554
+ return {
555
+ success: false,
556
+ error: (error as Error).message || 'Authentication failed',
557
+ }
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Clear auth state and storage. Does NOT handle sync/DB cleanup —
563
+ * the UI layer (BasicProvider) wraps this to add sync teardown.
564
+ */
565
+ async signOut(): Promise<void> {
566
+ log('signing out!')
567
+ this.resetAuthState('signed_out')
568
+
569
+ await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
570
+ await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT)
571
+ await this.clearStoredAuth()
572
+
573
+ this.broadcastSignOut()
574
+ this.notify()
575
+ }
576
+
577
+ async reconcileSession(
578
+ reason: string = 'manual',
579
+ options?: { forceRefresh?: boolean; throttleMs?: number },
580
+ ): Promise<void> {
581
+ if (this.authStatus === 'signed_out' || this.authStatus === 'reauth_required') {
582
+ return
583
+ }
395
584
 
396
- window.location.href = signInLink
585
+ if (!this.isOnline) {
586
+ this.updateAuthStatus('recovering', this.authErrorCode)
587
+ this.notify()
588
+ return
397
589
  }
398
590
 
399
- async signInWithHandle(handle: string): Promise<void> {
400
- log('signing in with handle:', handle)
591
+ const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS
592
+ const forceRefresh = options?.forceRefresh === true
593
+ const now = Date.now()
401
594
 
402
- if (!this.config.projectId) {
403
- throw new Error('Project ID is required for authentication')
404
- }
595
+ if (this.sessionCheckPromise) {
596
+ return this.sessionCheckPromise
597
+ }
405
598
 
406
- const resolved = await resolveHandle(handle)
407
- log('Resolved handle to PDS:', resolved.pdsUrl)
599
+ if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
600
+ return
601
+ }
408
602
 
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
603
+ this.lastSessionCheckAt = now
604
+
605
+ let sessionCheck: Promise<void> | null = null
606
+ sessionCheck = (async () => {
607
+ try {
608
+ const accessToken = await this.getToken(
609
+ forceRefresh ? { forceRefresh: true } : undefined,
610
+ )
611
+ const currentSession = await this.fetchCurrentSession(accessToken)
612
+ if (currentSession?.active) {
613
+ this.updateAuthStatus('authenticated')
614
+ this.notify()
615
+ if (!this.user) {
616
+ await this.recoverMissingUserProfile(reason, accessToken)
617
+ }
618
+ }
619
+ } catch (error) {
620
+ log(`Session reconciliation failed on ${reason}:`, error)
621
+ if (this.isDefinitiveAuthFailure(error)) {
622
+ return
623
+ }
624
+ if (this.isNetworkError(error)) {
625
+ this.updateAuthStatus('recovering', this.authErrorCode)
626
+ this.notify()
627
+ }
628
+ } finally {
629
+ if (this.sessionCheckPromise === sessionCheck) {
630
+ this.sessionCheckPromise = null
414
631
  }
632
+ }
633
+ })()
634
+
635
+ this.sessionCheckPromise = sessionCheck
636
+ return sessionCheck
637
+ }
638
+
639
+ hasScope(scope: string): boolean {
640
+ if (!this.tokenScope) return false
641
+ return this.tokenScope
642
+ .split(/[\s,]+/)
643
+ .filter(Boolean)
644
+ .includes(scope)
645
+ }
646
+
647
+ /**
648
+ * Returns scopes that were requested but not granted in the current token.
649
+ * Useful after login or when a 403 is returned.
650
+ */
651
+ missingScopes(): string[] {
652
+ const requested = this.requestedScopes.split(/[\s,]+/).filter(Boolean)
653
+ if (!this.tokenScope) return requested
654
+ const granted = new Set(this.tokenScope.split(/[\s,]+/).filter(Boolean))
655
+ return requested.filter((s) => !granted.has(s))
656
+ }
657
+
658
+ /**
659
+ * Register online/offline and visibility handlers that retry pending
660
+ * refreshes and proactively refresh tokens when the app resumes from
661
+ * background (critical for PWAs and mobile browsers where timers are
662
+ * frozen while backgrounded).
663
+ * Returns a cleanup function for useEffect teardown.
664
+ */
665
+ setupNetworkListeners(): () => void {
666
+ const handleOnline = async () => {
667
+ log('Network came back online')
668
+ this.isOnline = true
669
+ if (this.pendingRefresh) {
670
+ log('Retrying pending token refresh')
671
+ this.pendingRefresh = false
672
+ const refreshToken = await this.getRefreshToken()
673
+ if (refreshToken) {
674
+ this.exchangeToken(refreshToken, true).catch((error) => {
675
+ log('Retry refresh failed:', error)
676
+ })
677
+ }
678
+ }
679
+ if (this.isSignedIn) {
680
+ this.reconcileSession('online event', {
681
+ forceRefresh: true,
682
+ throttleMs: 0,
683
+ }).catch((error) => {
684
+ log('Session reconciliation on online failed:', error)
685
+ })
686
+ } else if (this.user) {
687
+ await this.restoreStoredSession('online restore')
688
+ }
689
+ }
690
+
691
+ const handleOffline = () => {
692
+ log('Network went offline')
693
+ this.isOnline = false
694
+ }
415
695
 
416
- const signInLink = await this.getSignInUrl(undefined, endpoints)
417
- log('Generated federated sign-in link:', signInLink)
696
+ const handleVisibilityChange = () => {
697
+ if (document.visibilityState === 'visible' && this.isSignedIn) {
698
+ log('App became visible - reconciling auth session')
699
+ this.reconcileSession('visibility resume', {
700
+ forceRefresh: true,
701
+ }).catch((err) => {
702
+ log('Session reconciliation on visibility resume failed:', err)
703
+ })
704
+ }
705
+ }
418
706
 
419
- try { new URL(signInLink) } catch {
420
- throw new Error('Failed to generate valid sign-in URL')
421
- }
707
+ window.addEventListener('online', handleOnline)
708
+ window.addEventListener('offline', handleOffline)
422
709
 
423
- window.location.href = signInLink
710
+ if (typeof document !== 'undefined') {
711
+ document.addEventListener('visibilitychange', handleVisibilityChange)
424
712
  }
425
713
 
426
- async signInWithCode(code: string, state?: string): Promise<AuthResult> {
427
- try {
428
- log('signInWithCode called with code:', code)
714
+ return () => {
715
+ window.removeEventListener('online', handleOnline)
716
+ window.removeEventListener('offline', handleOffline)
717
+ if (typeof document !== 'undefined') {
718
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
719
+ }
720
+ }
721
+ }
429
722
 
430
- if (!code || typeof code !== 'string') {
431
- return { success: false, error: 'Invalid authorization code' }
432
- }
723
+ // ------------------------------------------------------------------
724
+ // Private
725
+ // ------------------------------------------------------------------
433
726
 
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
- }
727
+ private get adminHostname(): string {
728
+ try {
729
+ return new URL(this.config.adminUrl).hostname
730
+ } catch {
731
+ return 'api.basic.tech'
732
+ }
733
+ }
734
+
735
+ private defaultPdsEndpoints(): PdsEndpoints {
736
+ return {
737
+ pds_url: this.config.pdsUrl,
738
+ authorization_endpoint: `${this.config.pdsUrl}/auth/authorize`,
739
+ token_endpoint: `${this.config.pdsUrl}/auth/token`,
740
+ userinfo_endpoint: `${this.config.pdsUrl}/auth/userinfo`,
741
+ }
742
+ }
743
+
744
+ private async getActivePdsEndpoints(): Promise<PdsEndpoints> {
745
+ const stored = await this.storage.get(STORAGE_KEYS.PDS_ENDPOINTS)
746
+ if (stored) {
747
+ try {
748
+ return JSON.parse(stored) as PdsEndpoints
749
+ } catch {
750
+ /* fall through */
751
+ }
752
+ }
753
+ return this.defaultPdsEndpoints()
754
+ }
755
+
756
+ private async reportConnection(accessToken: string): Promise<void> {
757
+ if (!this.config.projectId || !this.config.adminUrl) return
758
+ const lastReport = await this.storage.get(STORAGE_KEYS.LAST_CONNECT_REPORT)
759
+ if (lastReport) {
760
+ const elapsed = Date.now() - parseInt(lastReport, 10)
761
+ if (elapsed < 24 * 60 * 60 * 1000) return
762
+ }
763
+ try {
764
+ await fetch(
765
+ `${this.config.adminUrl}/project/${this.config.projectId}/user/connect`,
766
+ {
767
+ method: 'POST',
768
+ headers: { 'Content-Type': 'application/json' },
769
+ body: JSON.stringify({ token: accessToken }),
770
+ },
771
+ )
772
+ await this.storage.set(
773
+ STORAGE_KEYS.LAST_CONNECT_REPORT,
774
+ Date.now().toString(),
775
+ )
776
+ log('Reported connection to admin server')
777
+ } catch (err) {
778
+ log('Failed to report connection (non-blocking):', err)
779
+ }
780
+ }
781
+
782
+ /**
783
+ * After a new token is stored, decode JWT claims and fetch user info.
784
+ */
785
+ private async processNewToken(): Promise<void> {
786
+ if (!this.token) {
787
+ this.updateAuthStatus('signed_out')
788
+ this.notify()
789
+ return
790
+ }
441
791
 
442
- await this.storage.remove(STORAGE_KEYS.AUTH_STATE)
443
- cleanOAuthParamsFromUrl()
792
+ try {
793
+ const decoded = jwtDecode<JwtClaims>(this.token.access_token)
794
+ this.applyTokenClaims(decoded)
795
+ this.updateAuthStatus('authenticated')
796
+ this.notify()
797
+ this.broadcastSessionUpdate()
798
+ await this.fetchUser(this.token.access_token)
799
+ } catch (error) {
800
+ log('Error processing token:', error)
801
+ this.updateAuthStatus('recovering')
802
+ this.notify()
803
+ }
804
+ }
805
+
806
+ private async restoreCachedUser(options?: {
807
+ hasRecoverableSession: boolean
808
+ }): Promise<void> {
809
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO)
810
+ if (cached && options?.hasRecoverableSession) {
811
+ try {
812
+ this.user = JSON.parse(cached)
813
+ log('Restored cached user info for recoverable session')
814
+ } catch {
815
+ /* corrupted cache, ignore */
816
+ }
817
+ } else {
818
+ this.user = null
819
+ }
820
+ this.updateAuthStatus(
821
+ options?.hasRecoverableSession ? 'recovering' : 'signed_out',
822
+ )
823
+ this.notify()
824
+ }
825
+
826
+ private async fetchUser(accessToken: string): Promise<void> {
827
+ log('fetching user')
828
+ try {
829
+ const endpoints = await this.getActivePdsEndpoints()
830
+ const response = await fetch(endpoints.userinfo_endpoint, {
831
+ method: 'GET',
832
+ headers: { Authorization: `Bearer ${accessToken}` },
833
+ })
834
+
835
+ if (!response.ok) {
836
+ throw new Error(`Failed to fetch user info: ${response.status}`)
837
+ }
838
+
839
+ const user = await response.json()
840
+
841
+ if (user.error) {
842
+ log('error fetching user', user.error)
843
+ throw new Error(`User info error: ${user.error}`)
844
+ }
845
+
846
+ if (this.token?.refresh_token) {
847
+ await this.storage.set(
848
+ STORAGE_KEYS.REFRESH_TOKEN,
849
+ this.token.refresh_token,
850
+ )
851
+ }
852
+
853
+ await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user))
854
+ log('Cached user info in storage')
855
+
856
+ this.user = user
857
+ if (this.authStatus !== 'reauth_required') {
858
+ this.updateAuthStatus('authenticated')
859
+ }
860
+ this.nextUserRecoveryAt = 0
861
+ this.notify()
862
+ } catch (error) {
863
+ log('Failed to fetch user info:', error)
864
+ await this.handleUserFetchFailure()
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Exchange an auth code or refresh token for an access token.
870
+ * Handles mutex (one in-flight refresh), token validation, and
871
+ * triggers processNewToken on success.
872
+ */
873
+ private async exchangeToken(
874
+ codeOrRefreshToken: string,
875
+ isRefreshToken: boolean,
876
+ ): Promise<Token | null> {
877
+ if (!codeOrRefreshToken || codeOrRefreshToken.trim() === '') {
878
+ const errorMsg = isRefreshToken
879
+ ? 'Refresh token is empty or undefined'
880
+ : 'Authorization code is empty or undefined'
881
+ log('Error:', errorMsg)
882
+ throw new Error(errorMsg)
883
+ }
444
884
 
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
- }
885
+ if (isRefreshToken && this.refreshPromise) {
886
+ log('Reusing in-flight refresh token request')
887
+ return this.refreshPromise
460
888
  }
461
889
 
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()
890
+ if (!isRefreshToken && this.codeExchangePromise) {
891
+ log('Reusing in-flight code exchange request')
892
+ return this.codeExchangePromise
893
+ }
469
894
 
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
- }
895
+ const tokenPromise = (async (): Promise<Token | null> => {
896
+ try {
897
+ if (!this.isOnline) {
898
+ log('Network is offline, marking refresh as pending')
899
+ this.pendingRefresh = true
900
+ throw new Error(
901
+ 'Network offline - refresh will be retried when online',
902
+ )
515
903
  }
516
904
 
517
- const handleOffline = () => {
518
- log('Network went offline')
519
- this.isOnline = false
905
+ const endpoints = await this.getActivePdsEndpoints()
906
+ let requestBody: any
907
+
908
+ if (isRefreshToken) {
909
+ requestBody = {
910
+ grant_type: 'refresh_token',
911
+ refresh_token: codeOrRefreshToken,
912
+ }
913
+ if (this.config.projectId) {
914
+ requestBody.client_id = normalizeClientId(
915
+ this.config.projectId,
916
+ this.adminHostname,
917
+ )
918
+ }
919
+ } else {
920
+ requestBody = {
921
+ grant_type: 'authorization_code',
922
+ code: codeOrRefreshToken,
923
+ }
924
+
925
+ const storedRedirectUri = await this.storage.get(
926
+ STORAGE_KEYS.REDIRECT_URI,
927
+ )
928
+ if (storedRedirectUri) {
929
+ requestBody.redirect_uri = storedRedirectUri
930
+ log('Including redirect_uri in token exchange:', storedRedirectUri)
931
+ } else {
932
+ log('Warning: No redirect_uri found in storage for token exchange')
933
+ }
934
+
935
+ // PKCE: include code_verifier from the authorization request
936
+ const codeVerifier = await this.storage.get(
937
+ STORAGE_KEYS.CODE_VERIFIER,
938
+ )
939
+ if (codeVerifier) {
940
+ requestBody.code_verifier = codeVerifier
941
+ }
942
+
943
+ if (this.config.projectId) {
944
+ requestBody.client_id = normalizeClientId(
945
+ this.config.projectId,
946
+ this.adminHostname,
947
+ )
948
+ }
520
949
  }
521
950
 
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
- })
951
+ log('Token exchange request body:', {
952
+ ...requestBody,
953
+ ...(isRefreshToken
954
+ ? { refresh_token: '[REDACTED]' }
955
+ : { code: '[REDACTED]' }),
956
+ ...(requestBody.code_verifier ? { code_verifier: '[REDACTED]' } : {}),
957
+ })
958
+
959
+ const token = await fetch(endpoints.token_endpoint, {
960
+ method: 'POST',
961
+ headers: { 'Content-Type': 'application/json' },
962
+ body: JSON.stringify(requestBody),
963
+ })
964
+ .then((response) => response.json())
965
+ .catch((error) => {
966
+ log('Network error fetching token:', error)
967
+ if (!this.isOnline) {
968
+ this.pendingRefresh = true
969
+ throw new Error(
970
+ 'Network offline - refresh will be retried when online',
971
+ )
972
+ }
973
+ throw new Error('Network error during token refresh')
974
+ })
975
+
976
+ if (token.access_token) {
977
+ try {
978
+ const decoded = jwtDecode<{ typ?: string }>(token.access_token)
979
+ if (decoded.typ === 'refresh') {
980
+ log('Error: received refresh token as access token')
981
+ throw new Error(
982
+ 'Invalid token: received refresh token instead of access token',
983
+ )
984
+ }
985
+ } catch (decodeError) {
986
+ if ((decodeError as Error).message.includes('Invalid token')) {
987
+ throw decodeError
528
988
  }
989
+ log(
990
+ 'Warning: could not decode access token for type check:',
991
+ decodeError,
992
+ )
993
+ }
529
994
  }
530
995
 
531
- window.addEventListener('online', handleOnline)
532
- window.addEventListener('offline', handleOffline)
996
+ if (token.error) {
997
+ log('error fetching token', token.error)
998
+
999
+ if (
1000
+ typeof token.error === 'string' &&
1001
+ (token.error.includes('network') || token.error.includes('timeout'))
1002
+ ) {
1003
+ this.pendingRefresh = true
1004
+ throw new Error(
1005
+ 'Network issue - refresh will be retried when online',
1006
+ )
1007
+ }
1008
+
1009
+ // Only clear stored auth on definitive OAuth rejection.
1010
+ // Transient server errors (500, 503, etc.) should NOT wipe
1011
+ // the refresh token — the user can retry later.
1012
+ if (this.isDefinitiveTokenErrorCode(token.error)) {
1013
+ await this.markReauthRequired(token.error)
1014
+ throw new DefinitiveAuthError(token.error)
1015
+ }
1016
+ throw new Error(`Token refresh failed: ${token.error}`)
1017
+ } else {
1018
+ if (!token.access_token) {
1019
+ throw new Error('Token response missing access token')
1020
+ }
1021
+ this.token = token
1022
+ this.pendingRefresh = false
1023
+
1024
+ if (token.refresh_token) {
1025
+ await this.storage.set(
1026
+ STORAGE_KEYS.REFRESH_TOKEN,
1027
+ token.refresh_token,
1028
+ )
1029
+ log('Updated refresh token in storage')
1030
+ }
1031
+
1032
+ if (!isRefreshToken) {
1033
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
1034
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
1035
+ log(
1036
+ 'Cleaned up redirect_uri and code_verifier from storage after successful exchange',
1037
+ )
1038
+ }
1039
+
1040
+ this.reportConnection(token.access_token).catch(() => {})
1041
+
1042
+ await this.processNewToken()
1043
+ }
533
1044
 
534
- if (typeof document !== 'undefined') {
535
- document.addEventListener('visibilitychange', handleVisibilityChange)
1045
+ return token
1046
+ } catch (error) {
1047
+ log('Token refresh error:', error)
1048
+ if (this.isDefinitiveAuthFailure(error)) {
1049
+ log('Preserving cleared auth state after definitive token rejection')
1050
+ } else if (this.isNetworkError(error)) {
1051
+ log('Recoverable network auth failure - preserving session state')
1052
+ } else {
1053
+ log('Recoverable auth failure - preserving session state')
536
1054
  }
537
1055
 
538
- return () => {
539
- window.removeEventListener('online', handleOnline)
540
- window.removeEventListener('offline', handleOffline)
541
- if (typeof document !== 'undefined') {
542
- document.removeEventListener('visibilitychange', handleVisibilityChange)
543
- }
1056
+ throw error
1057
+ }
1058
+ })()
1059
+
1060
+ if (isRefreshToken) {
1061
+ this.refreshPromise = tokenPromise
1062
+ tokenPromise.finally(() => {
1063
+ if (this.refreshPromise === tokenPromise) {
1064
+ this.refreshPromise = null
1065
+ log('Cleared refresh promise reference')
1066
+ }
1067
+ })
1068
+ } else {
1069
+ this.codeExchangePromise = tokenPromise
1070
+ tokenPromise.finally(() => {
1071
+ if (this.codeExchangePromise === tokenPromise) {
1072
+ this.codeExchangePromise = null
1073
+ log('Cleared code exchange promise reference')
544
1074
  }
1075
+ })
545
1076
  }
546
1077
 
547
- // ------------------------------------------------------------------
548
- // Private
549
- // ------------------------------------------------------------------
1078
+ return tokenPromise
1079
+ }
550
1080
 
551
- private get adminHostname(): string {
552
- try { return new URL(this.config.adminUrl).hostname }
553
- catch { return 'api.basic.tech' }
1081
+ private resetAuthState(status: AuthStatus = 'signed_out'): void {
1082
+ this.user = status === 'reauth_required' ? this.user : null
1083
+ this.token = null
1084
+ if (status !== 'reauth_required') {
1085
+ this.did = null
554
1086
  }
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
- }
1087
+ this.tokenScope = null
1088
+ this.nextUserRecoveryAt = 0
1089
+ this.updateAuthStatus(status)
1090
+ }
1091
+
1092
+ private async clearStoredAuth(): Promise<void> {
1093
+ await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN)
1094
+ await this.storage.remove(STORAGE_KEYS.USER_INFO)
1095
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
1096
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
1097
+ await this.storage.remove(STORAGE_KEYS.SERVER_URL)
1098
+ await this.storage.remove(STORAGE_KEYS.PDS_ENDPOINTS)
1099
+ }
1100
+
1101
+ private isNetworkError(error: unknown): boolean {
1102
+ if (error instanceof TypeError) return true
1103
+ if (error instanceof Error) {
1104
+ return (
1105
+ error.message.includes('offline') || error.message.includes('Network')
1106
+ )
563
1107
  }
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()
1108
+ return false
1109
+ }
1110
+
1111
+ private async getRefreshToken(): Promise<string | null> {
1112
+ const storedRefreshToken = await this.storage.get(
1113
+ STORAGE_KEYS.REFRESH_TOKEN,
1114
+ )
1115
+ if (storedRefreshToken) {
1116
+ log('Using refresh token from storage')
1117
+ if (this.token && this.token.refresh_token !== storedRefreshToken) {
1118
+ this.token = { ...this.token, refresh_token: storedRefreshToken }
1119
+ }
1120
+ return storedRefreshToken
571
1121
  }
572
1122
 
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
- }
1123
+ const memoryRefreshToken = this.token?.refresh_token ?? null
1124
+ if (memoryRefreshToken) {
1125
+ log('Using refresh token from memory fallback')
1126
+ } else {
1127
+ log('No refresh token available in storage or memory')
1128
+ }
1129
+ return memoryRefreshToken
1130
+ }
1131
+
1132
+ private async syncRefreshTokenFromStorage(): Promise<void> {
1133
+ const storedRefreshToken = await this.storage.get(
1134
+ STORAGE_KEYS.REFRESH_TOKEN,
1135
+ )
1136
+ if (
1137
+ storedRefreshToken &&
1138
+ this.token &&
1139
+ this.token.refresh_token !== storedRefreshToken
1140
+ ) {
1141
+ this.token = { ...this.token, refresh_token: storedRefreshToken }
1142
+ log('Synced refresh token from shared storage into memory')
1143
+ }
1144
+ }
1145
+
1146
+ private applyTokenClaims(decoded: JwtClaims): void {
1147
+ this.did = decoded.sub || null
1148
+ this.tokenScope = decoded.scope || null
1149
+ }
1150
+
1151
+ private broadcastSessionUpdate(): void {
1152
+ if (this.freshSignIn) {
1153
+ this.freshSignIn = false
1154
+ this.broadcastSignIn()
1155
+ } else {
1156
+ this.broadcastTokenRefresh()
1157
+ }
1158
+ }
1159
+
1160
+ private async handleUserFetchFailure(): Promise<void> {
1161
+ if (this.isCompatibleUser(this.user)) {
1162
+ log('Preserving existing user after userinfo failure')
1163
+ } else if (this.user) {
1164
+ log('Discarding stale in-memory user after userinfo failure')
1165
+ this.user = null
591
1166
  }
592
1167
 
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
-
1168
+ if (!this.user) {
1169
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO)
1170
+ if (cached) {
603
1171
  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)
1172
+ const parsed = JSON.parse(cached) as User
1173
+ if (this.isCompatibleUser(parsed)) {
1174
+ this.user = parsed
1175
+ log('Recovered cached user after userinfo failure')
1176
+ } else {
1177
+ log('Cached user did not match the active session')
1178
+ }
610
1179
  } catch (error) {
611
- log('Error processing token:', error)
612
- this.isAuthReady = true
613
- this.notify()
1180
+ log('Failed to parse cached user after userinfo failure:', error)
614
1181
  }
1182
+ }
615
1183
  }
616
1184
 
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()
1185
+ if (!this.user) {
1186
+ log('No compatible cached user available after userinfo failure')
1187
+ this.nextUserRecoveryAt = Date.now() + USER_RECOVERY_RETRY_COOLDOWN_MS
1188
+ } else {
1189
+ this.nextUserRecoveryAt = 0
628
1190
  }
629
1191
 
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
- }
1192
+ if (this.authStatus === 'bootstrapping') {
1193
+ this.updateAuthStatus(this.token ? 'authenticated' : 'recovering')
1194
+ }
1195
+ this.notify()
1196
+ }
1197
+
1198
+ private isCompatibleUser(user: User | null): boolean {
1199
+ if (!user) return false
1200
+ if (!this.did) return true
1201
+ return user.sub === this.did
1202
+ }
1203
+
1204
+ private async recoverMissingUserProfile(
1205
+ reason: string,
1206
+ accessToken?: string,
1207
+ ): Promise<void> {
1208
+ if (!this.isSignedIn || this.user) return
1209
+ const now = Date.now()
1210
+ if (this.nextUserRecoveryAt > now) {
1211
+ log(
1212
+ `Skipping user profile recovery on ${reason} until ${new Date(this.nextUserRecoveryAt).toISOString()}`,
1213
+ )
1214
+ return
1215
+ }
1216
+ log(`Attempting user profile recovery on ${reason}`)
1217
+ const token = accessToken ?? (await this.getToken())
1218
+ if (this.user) return
1219
+ await this.fetchUser(token)
1220
+ }
1221
+
1222
+ private isDefinitiveTokenErrorCode(code: unknown): code is string {
1223
+ return typeof code === 'string' && DEFINITIVE_TOKEN_ERRORS.has(code)
1224
+ }
1225
+
1226
+ private isDefinitiveAuthFailure(error: unknown): boolean {
1227
+ return error instanceof DefinitiveAuthError
1228
+ }
1229
+
1230
+ /**
1231
+ * Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
1232
+ * from the status so they stay consistent.
1233
+ *
1234
+ * `isSignedIn` is intentionally `true` during `reauth_required` so the
1235
+ * UI layer can still display user info while prompting re-authentication.
1236
+ * Consumers should check `authStatus` (or a future convenience getter)
1237
+ * when they need to distinguish "healthy session" from "needs re-auth".
1238
+ */
1239
+ private updateAuthStatus(
1240
+ status: AuthStatus,
1241
+ errorCode: string | null = null,
1242
+ ): void {
1243
+ this.authStatus = status
1244
+ this.authErrorCode = errorCode
1245
+ this.isSignedIn =
1246
+ status === 'authenticated' ||
1247
+ status === 'recovering' ||
1248
+ status === 'reauth_required'
1249
+ this.isAuthReady = status !== 'bootstrapping'
1250
+ }
1251
+
1252
+ private async clearStoredSessionTokens(): Promise<void> {
1253
+ await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN)
1254
+ await this.storage.remove(STORAGE_KEYS.REDIRECT_URI)
1255
+ await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER)
1256
+ }
1257
+
1258
+ private async restoreStoredSession(reason: string): Promise<void> {
1259
+ const refreshToken = await this.getRefreshToken()
1260
+ if (!refreshToken) {
1261
+ log(`No stored refresh token available during ${reason}`)
1262
+ await this.restoreCachedUser({ hasRecoverableSession: false })
1263
+ return
678
1264
  }
679
1265
 
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
- }
1266
+ log(`Restoring stored session during ${reason}`)
1267
+ await this.restoreCachedUser({ hasRecoverableSession: true })
691
1268
 
692
- if (isRefreshToken && this.refreshPromise) {
693
- log('Reusing in-flight refresh token request')
694
- return this.refreshPromise
695
- }
1269
+ if (!this.isOnline) {
1270
+ return
1271
+ }
696
1272
 
697
- if (!isRefreshToken && this.codeExchangePromise) {
698
- log('Reusing in-flight code exchange request')
699
- return this.codeExchangePromise
1273
+ this.exchangeToken(refreshToken, true).catch(async (error) => {
1274
+ log(`Stored session refresh failed during ${reason}:`, error)
1275
+ if (this.isDefinitiveAuthFailure(error)) {
1276
+ return
1277
+ }
1278
+ await this.restoreCachedUser({ hasRecoverableSession: true })
1279
+ })
1280
+ }
1281
+
1282
+ private async handleExternalTokenRefresh(data: {
1283
+ accessToken?: string
1284
+ did?: string
1285
+ tokenScope?: string
1286
+ }): Promise<void> {
1287
+ await this.syncRefreshTokenFromStorage()
1288
+ const refreshToken = await this.getRefreshToken()
1289
+ if (data.accessToken && refreshToken) {
1290
+ try {
1291
+ const decoded = jwtDecode<JwtClaims>(data.accessToken)
1292
+ const expiresIn =
1293
+ decoded.exp != null
1294
+ ? Math.max(0, decoded.exp - Math.floor(Date.now() / 1000))
1295
+ : 0
1296
+ this.token = {
1297
+ access_token: data.accessToken,
1298
+ token_type: 'Bearer',
1299
+ expires_in: expiresIn,
1300
+ refresh_token: refreshToken,
700
1301
  }
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
- })
1302
+ this.applyTokenClaims(decoded)
1303
+ } catch (error) {
1304
+ log('Failed to decode token refreshed by another tab:', error)
1305
+ this.token = {
1306
+ access_token: data.accessToken,
1307
+ token_type: 'Bearer',
1308
+ expires_in: 0,
1309
+ refresh_token: refreshToken,
852
1310
  }
853
-
854
- return tokenPromise
1311
+ }
1312
+ if (this.authStatus !== 'reauth_required') {
1313
+ this.updateAuthStatus('authenticated')
1314
+ }
1315
+ } else if (refreshToken) {
1316
+ await this.restoreCachedUser({ hasRecoverableSession: true })
855
1317
  }
856
1318
 
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
1319
+ if (data.did) this.did = data.did
1320
+ if (data.tokenScope) this.tokenScope = data.tokenScope
1321
+ this.notify()
1322
+ }
1323
+
1324
+ private async fetchCurrentSession(
1325
+ accessToken: string,
1326
+ ): Promise<CurrentSessionInfo | null> {
1327
+ const endpoints = await this.getActivePdsEndpoints()
1328
+ const response = await fetch(`${endpoints.pds_url}/auth/session`, {
1329
+ method: 'GET',
1330
+ headers: { Authorization: `Bearer ${accessToken}` },
1331
+ })
1332
+ const data = (await response.json().catch(() => ({}))) as CurrentSessionInfo
1333
+
1334
+ if (response.status === 401 && data.reauth_required) {
1335
+ await this.markReauthRequired(data.error || 'invalid_session')
1336
+ throw new DefinitiveAuthError(data.error || 'invalid_session')
864
1337
  }
865
1338
 
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)
1339
+ if (!response.ok) {
1340
+ throw new Error(`Failed to reconcile session: ${response.status}`)
873
1341
  }
874
1342
 
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')
1343
+ return data
1344
+ }
1345
+
1346
+ private async markReauthRequired(
1347
+ code: string,
1348
+ options?: { broadcast?: boolean },
1349
+ ): Promise<void> {
1350
+ log('Marking auth session as requiring reauthentication:', code)
1351
+ await this.clearStoredSessionTokens()
1352
+ if (!this.user) {
1353
+ const cached = await this.storage.get(STORAGE_KEYS.USER_INFO)
1354
+ if (cached) {
1355
+ try {
1356
+ this.user = JSON.parse(cached)
1357
+ } catch {
1358
+ /* ignore corrupted cache */
879
1359
  }
880
- return false
1360
+ }
1361
+ }
1362
+ this.token = null
1363
+ this.tokenScope = null
1364
+ this.nextUserRecoveryAt = 0
1365
+ this.updateAuthStatus('reauth_required', code)
1366
+ if (options?.broadcast !== false) {
1367
+ this.broadcastSessionInvalidated(code)
881
1368
  }
1369
+ this.notify()
1370
+ }
882
1371
  }