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