@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.
- package/changelog.md +6 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +537 -141
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +545 -142
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/readme.md +99 -97
- package/src/AuthContext.tsx +513 -415
- package/src/context.tsx +19 -1
- package/src/core/auth/AuthManager.ts +1235 -746
- package/src/sync/syncProtocol.js +23 -7
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
const array = new Uint8Array(32)
|
|
30
|
+
crypto.getRandomValues(array)
|
|
31
|
+
return base64UrlEncode(array)
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
async function generateCodeChallenge(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
success: boolean
|
|
99
|
+
error?: string
|
|
100
|
+
code?: string
|
|
53
101
|
}
|
|
54
102
|
|
|
55
103
|
export type GetTokenOptions = {
|
|
56
|
-
|
|
104
|
+
forceRefresh?: boolean
|
|
57
105
|
}
|
|
58
106
|
|
|
59
107
|
export type PdsEndpoints = {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
271
|
+
try {
|
|
272
|
+
const params = new URLSearchParams(window.location.search)
|
|
174
273
|
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
338
|
-
|
|
409
|
+
} else {
|
|
410
|
+
throw new Error('no refresh token available')
|
|
411
|
+
}
|
|
339
412
|
}
|
|
340
413
|
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
419
|
+
async getSignInUrl(
|
|
420
|
+
redirectUri?: string,
|
|
421
|
+
endpoints?: PdsEndpoints,
|
|
422
|
+
): Promise<string> {
|
|
423
|
+
log('getting sign in link...')
|
|
347
424
|
|
|
348
|
-
|
|
349
|
-
|
|
425
|
+
if (!this.config.projectId) {
|
|
426
|
+
throw new Error('Project ID is required to generate sign-in link')
|
|
427
|
+
}
|
|
350
428
|
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
479
|
+
const signInLink = await this.getSignInUrl(redirectUri)
|
|
480
|
+
log('Generated sign-in link:', signInLink)
|
|
361
481
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
377
|
-
|
|
495
|
+
if (!this.config.projectId) {
|
|
496
|
+
throw new Error('Project ID is required for authentication')
|
|
378
497
|
}
|
|
379
498
|
|
|
380
|
-
|
|
381
|
-
|
|
499
|
+
const resolved = await resolveHandle(handle)
|
|
500
|
+
log('Resolved handle to PDS:', resolved.pdsUrl)
|
|
382
501
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
509
|
+
const signInLink = await this.getSignInUrl(undefined, endpoints)
|
|
510
|
+
log('Generated federated sign-in link:', signInLink)
|
|
390
511
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
585
|
+
if (!this.isOnline) {
|
|
586
|
+
this.updateAuthStatus('recovering', this.authErrorCode)
|
|
587
|
+
this.notify()
|
|
588
|
+
return
|
|
397
589
|
}
|
|
398
590
|
|
|
399
|
-
|
|
400
|
-
|
|
591
|
+
const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS
|
|
592
|
+
const forceRefresh = options?.forceRefresh === true
|
|
593
|
+
const now = Date.now()
|
|
401
594
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
595
|
+
if (this.sessionCheckPromise) {
|
|
596
|
+
return this.sessionCheckPromise
|
|
597
|
+
}
|
|
405
598
|
|
|
406
|
-
|
|
407
|
-
|
|
599
|
+
if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
|
|
600
|
+
return
|
|
601
|
+
}
|
|
408
602
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
707
|
+
window.addEventListener('online', handleOnline)
|
|
708
|
+
window.addEventListener('offline', handleOffline)
|
|
422
709
|
|
|
423
|
-
|
|
710
|
+
if (typeof document !== 'undefined') {
|
|
711
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
424
712
|
}
|
|
425
713
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
723
|
+
// ------------------------------------------------------------------
|
|
724
|
+
// Private
|
|
725
|
+
// ------------------------------------------------------------------
|
|
433
726
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
549
|
-
// ------------------------------------------------------------------
|
|
1078
|
+
return tokenPromise
|
|
1079
|
+
}
|
|
550
1080
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
1269
|
+
if (!this.isOnline) {
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
696
1272
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
867
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|
}
|