@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.14

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2880 -1667
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +810 -169
  17. package/dist/server.d.ts +810 -169
  18. package/dist/server.js +2848 -1646
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +365 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +335 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +285 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +338 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,1592 @@
1
+ import { KoraError } from '@korajs/core'
2
+ import type { AuthDeviceIdentityProvider } from './device-session'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Auth-specific error
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Thrown when an authentication operation fails.
10
+ * Includes a machine-readable code and optional context for debugging.
11
+ */
12
+ export class AuthError extends KoraError {
13
+ constructor(message: string, code: string, context?: Record<string, unknown>) {
14
+ super(message, code, context)
15
+ this.name = 'AuthError'
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Thrown by sign-in when the account requires a second factor (AUTH-10).
21
+ * Complete it with {@link AuthClient.verifyMfa} using {@link mfaToken}.
22
+ */
23
+ export class MfaRequiredError extends AuthError {
24
+ constructor(public readonly mfaToken: string) {
25
+ super('A second factor is required to finish signing in.', 'AUTH_MFA_REQUIRED')
26
+ this.name = 'MfaRequiredError'
27
+ }
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Possible authentication states for the client.
36
+ * - 'loading': Initial state while restoring tokens from storage
37
+ * - 'authenticated': A session exists. It may be fresh or offline; see
38
+ * {@link AuthClient.session} for the freshness of its credentials.
39
+ * - 'unauthenticated': No session exists (never signed in, signed out, or the
40
+ * auth server definitively rejected the session)
41
+ */
42
+ export type AuthState = 'loading' | 'authenticated' | 'unauthenticated'
43
+
44
+ /**
45
+ * Freshness of an authenticated session.
46
+ * - 'fresh': the last refresh or profile request reached the auth server
47
+ * - 'offline': authenticated-offline. The identity is known from stored
48
+ * credentials, but no fresh access token can be minted right now (network,
49
+ * timeout, 5xx, captive portal...). Local data stays available; sync waits.
50
+ * - 'locked': offline for longer than `maxOfflineGraceMs`, or the device clock
51
+ * moved backwards. The UI should lock; local data is never wiped.
52
+ */
53
+ export type AuthSessionStatus = 'fresh' | 'offline' | 'locked'
54
+
55
+ /**
56
+ * The identity of the stored session, independent of token freshness.
57
+ */
58
+ export interface AuthClientSession {
59
+ /** User id (`sub` of the stored credentials). */
60
+ userId: string
61
+ /** Device id (`dev` of the stored credentials), when known. */
62
+ deviceId: string | null
63
+ /** Freshness of the credentials. */
64
+ status: AuthSessionStatus
65
+ /** Last successful contact with the auth server (ms since epoch). */
66
+ lastServerContactAt: number
67
+ }
68
+
69
+ /**
70
+ * Authenticated user information.
71
+ */
72
+ export interface AuthUser {
73
+ /** Unique user identifier */
74
+ id: string
75
+
76
+ /** User email address */
77
+ email: string
78
+
79
+ /** Display name (may be absent if user did not provide one) */
80
+ name: string | null
81
+ }
82
+
83
+ export interface LinkedOAuthAccount {
84
+ id: string
85
+ userId: string
86
+ provider: string
87
+ providerUserId: string
88
+ email: string | null
89
+ linkedAt: number
90
+ }
91
+
92
+ export interface OAuthAuthorizationResult {
93
+ url: string
94
+ state: string
95
+ /**
96
+ * Client binding for this flow (AUTH-3). The client keeps it (session storage
97
+ * on the web, memory on native) and presents it with the callback; a callback
98
+ * carrying someone else's code and state is then refused.
99
+ */
100
+ binding?: string
101
+ }
102
+
103
+ export interface OAuthAuthorizationOptions {
104
+ /**
105
+ * Redirect the current browser window to the provider after creating the URL.
106
+ * Defaults to true when `window.location.assign` is available.
107
+ */
108
+ redirect?: boolean
109
+ /**
110
+ * Optional app-specific return path stored in OAuth state metadata.
111
+ */
112
+ returnTo?: string
113
+ /**
114
+ * Optional extra metadata stored in OAuth state. Use this for app handoff data.
115
+ */
116
+ metadata?: Record<string, unknown>
117
+ deviceId?: string
118
+ devicePublicKey?: string
119
+ }
120
+
121
+ export interface OAuthCallbackParams {
122
+ code: string
123
+ state: string
124
+ /** Flow binding; looked up from the started flow when omitted. */
125
+ binding?: string
126
+ deviceId?: string
127
+ devicePublicKey?: string
128
+ }
129
+
130
+ /**
131
+ * Configuration for the AuthClient.
132
+ */
133
+ export interface AuthClientConfig {
134
+ /** Base URL of the auth server (e.g. 'http://localhost:3001') */
135
+ serverUrl: string
136
+
137
+ /** Storage key prefix for tokens. Defaults to 'kora_auth' */
138
+ storageKey?: string
139
+
140
+ /**
141
+ * Optional token storage adapter.
142
+ *
143
+ * Use this for runtimes where localStorage is not the right place for
144
+ * credentials, such as React Native/Expo SecureStore, iOS Keychain,
145
+ * Android Keystore, or a Tauri secure storage plugin.
146
+ */
147
+ storage?: AuthTokenStorage
148
+
149
+ /**
150
+ * Optional fetch implementation. Defaults to globalThis.fetch.
151
+ * Useful for tests, SSR adapters, and mobile runtimes with a custom fetch.
152
+ */
153
+ fetch?: typeof fetch
154
+
155
+ /**
156
+ * Optional local device identity provider.
157
+ *
158
+ * When configured, sign-up and sign-in automatically include stable
159
+ * `deviceId` and `devicePublicKey` fields unless the caller provides them.
160
+ */
161
+ deviceIdentity?: AuthDeviceIdentityProvider
162
+
163
+ /**
164
+ * Timeout for every auth request, in milliseconds. A request that has not
165
+ * answered by then is aborted and treated as a transient failure.
166
+ * @default 20000
167
+ */
168
+ requestTimeoutMs?: number
169
+
170
+ /**
171
+ * How long a session may stay authenticated-offline (no successful contact
172
+ * with the auth server) before it is `locked`. Locking never wipes local data
173
+ * or tokens; it only tells the UI to ask the user to reconnect.
174
+ * Defaults to no limit beyond the refresh token's own expiry.
175
+ */
176
+ maxOfflineGraceMs?: number
177
+
178
+ /**
179
+ * Backoff between refresh attempts after transient failures. The first retry
180
+ * after a failure is immediate (it recovers a response lost on the wire);
181
+ * later ones back off exponentially with jitter, honouring `Retry-After`.
182
+ */
183
+ refreshBackoff?: { baseDelayMs?: number; maxDelayMs?: number }
184
+ }
185
+
186
+ type MaybePromise<T> = T | Promise<T>
187
+
188
+ /**
189
+ * Token pair returned by the auth server on sign-up, sign-in, and refresh.
190
+ */
191
+ interface AuthTokensResponse {
192
+ accessToken: string
193
+ refreshToken: string
194
+ }
195
+
196
+ /**
197
+ * Sign-up and sign-in responses include user data alongside tokens.
198
+ */
199
+ interface AuthSignInResponse {
200
+ user: { id: string; email: string; name: string | null }
201
+ tokens: AuthTokensResponse
202
+ }
203
+
204
+ interface OAuthSignInResponse extends AuthSignInResponse {
205
+ identity: LinkedOAuthAccount
206
+ }
207
+
208
+ interface MfaChallengeResponse {
209
+ mfaRequired: true
210
+ mfaToken: string
211
+ }
212
+
213
+ /**
214
+ * User profile returned by the /auth/me endpoint.
215
+ */
216
+ interface UserProfileResponse {
217
+ id: string
218
+ email: string
219
+ name: string | null
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Internal helpers
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** Number of seconds before actual expiry at which we consider a token expired. */
227
+ const EXPIRY_BUFFER_SECONDS = 30
228
+
229
+ const DEFAULT_REQUEST_TIMEOUT_MS = 20_000
230
+ const DEFAULT_BACKOFF_BASE_MS = 2_000
231
+ const DEFAULT_BACKOFF_MAX_MS = 5 * 60_000
232
+
233
+ /**
234
+ * Tolerated backwards clock movement before a session is treated as tampered.
235
+ * Matches the server's own skew allowance order of magnitude.
236
+ */
237
+ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60_000
238
+
239
+ /**
240
+ * Decode the payload portion of a JWT without verifying the signature.
241
+ * Client-side only -- verification is the server's responsibility.
242
+ *
243
+ * Returns null if the token is malformed.
244
+ */
245
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
246
+ const parts = token.split('.')
247
+ if (parts.length !== 3) {
248
+ return null
249
+ }
250
+
251
+ try {
252
+ // Base64url -> standard base64
253
+ const base64 = (parts[1] as string).replace(/-/g, '+').replace(/_/g, '/')
254
+ const json = atob(base64)
255
+ const parsed: unknown = JSON.parse(json)
256
+ return isRecord(parsed) ? parsed : null
257
+ } catch {
258
+ return null
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Returns true if the JWT's `exp` claim is in the past (with a small buffer).
264
+ * If the token cannot be decoded, returns true (treat as expired).
265
+ */
266
+ function isTokenExpired(token: string, bufferSeconds = EXPIRY_BUFFER_SECONDS): boolean {
267
+ const payload = decodeJwtPayload(token)
268
+ if (!payload || typeof payload.exp !== 'number') {
269
+ return true
270
+ }
271
+ const nowSeconds = Math.floor(Date.now() / 1000)
272
+ return payload.exp <= nowSeconds + bufferSeconds
273
+ }
274
+
275
+ /** Issue time of a token in ms, from `iatMs` or `iat`. */
276
+ function tokenIssuedAtMs(token: string): number | null {
277
+ const payload = decodeJwtPayload(token)
278
+ if (!payload) return null
279
+ if (typeof payload.iatMs === 'number') return payload.iatMs
280
+ return typeof payload.iat === 'number' ? payload.iat * 1000 : null
281
+ }
282
+
283
+ function isRecord(value: unknown): value is Record<string, unknown> {
284
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
285
+ }
286
+
287
+ function getDefaultFetch(): typeof fetch {
288
+ if (typeof globalThis.fetch !== 'function') {
289
+ return async () => {
290
+ throw new AuthError(
291
+ 'No fetch implementation is available in this runtime. Pass `fetch` to AuthClientConfig.',
292
+ 'AUTH_FETCH_UNAVAILABLE',
293
+ )
294
+ }
295
+ }
296
+ return globalThis.fetch.bind(globalThis)
297
+ }
298
+
299
+ function normalizeAuthUser(user: { id: string; email: string; name?: string | null }): AuthUser {
300
+ return {
301
+ id: user.id,
302
+ email: user.email,
303
+ name: user.name ?? null,
304
+ }
305
+ }
306
+
307
+ function canRedirectCurrentWindow(): boolean {
308
+ return (
309
+ typeof globalThis.window !== 'undefined' &&
310
+ typeof globalThis.window.location?.assign === 'function'
311
+ )
312
+ }
313
+
314
+ function redirectCurrentWindow(url: string): void {
315
+ if (!canRedirectCurrentWindow()) {
316
+ throw new AuthError(
317
+ 'OAuth redirect is not available in this runtime. Pass redirect: false and open the returned URL with your platform browser API.',
318
+ 'AUTH_OAUTH_REDIRECT_UNAVAILABLE',
319
+ )
320
+ }
321
+ globalThis.window.location.assign(url)
322
+ }
323
+
324
+ const OAUTH_BINDING_PREFIX = 'kora_oauth_binding:'
325
+
326
+ function getSessionStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null {
327
+ try {
328
+ const storage = (globalThis as { sessionStorage?: Storage }).sessionStorage
329
+ return storage && typeof storage.getItem === 'function' ? storage : null
330
+ } catch {
331
+ return null
332
+ }
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Refresh outcome classification (AUTH-13, LMS-1)
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * Outcome of one refresh attempt. Only `rejected` ends a session, and only when
341
+ * the auth server itself said so.
342
+ */
343
+ type RefreshOutcome =
344
+ | { kind: 'ok'; accessToken: string }
345
+ | { kind: 'rejected' }
346
+ | { kind: 'transient'; retryAfterMs?: number }
347
+
348
+ /** Body codes the Kora auth server uses to reject a refresh token. */
349
+ const DEFINITIVE_REFRESH_CODES = new Set(['REFRESH_TOKEN_INVALID', 'invalid_grant'])
350
+
351
+ interface RawResponse {
352
+ status: number
353
+ ok: boolean
354
+ /** Parsed JSON body, or undefined when the body is not JSON (proxy/captive portal). */
355
+ json: unknown
356
+ retryAfterMs?: number
357
+ }
358
+
359
+ /**
360
+ * A response is a definitive rejection only when it is a 401 (or a 400
361
+ * `invalid_grant`) whose body is a Kora JSON error. Captive portals, proxies and
362
+ * load balancers also answer 401/403/407 or HTML, and must never sign a user out.
363
+ */
364
+ function isDefinitiveRejection(response: RawResponse): boolean {
365
+ if (!isRecord(response.json)) return false
366
+ const code = typeof response.json.code === 'string' ? response.json.code : undefined
367
+ const error = typeof response.json.error === 'string' ? response.json.error : undefined
368
+ if (response.status === 401) return code !== undefined || error !== undefined
369
+ if (response.status === 400) {
370
+ return (
371
+ (code !== undefined && DEFINITIVE_REFRESH_CODES.has(code)) ||
372
+ (error !== undefined && DEFINITIVE_REFRESH_CODES.has(error))
373
+ )
374
+ }
375
+ return false
376
+ }
377
+
378
+ function parseRetryAfter(value: string | null | undefined): number | undefined {
379
+ if (!value) return undefined
380
+ const seconds = Number(value)
381
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
382
+ const date = Date.parse(value)
383
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now())
384
+ }
385
+
386
+ function readTokenPair(json: unknown): AuthTokensResponse | null {
387
+ if (!isRecord(json)) return null
388
+ const data = json.data !== undefined ? json.data : json
389
+ if (!isRecord(data)) return null
390
+ return typeof data.accessToken === 'string' && typeof data.refreshToken === 'string'
391
+ ? { accessToken: data.accessToken, refreshToken: data.refreshToken }
392
+ : null
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Single refresher across tabs (NEW-AUTH-2)
397
+ // ---------------------------------------------------------------------------
398
+
399
+ interface WebLockManager {
400
+ request<T>(
401
+ name: string,
402
+ options: { signal?: AbortSignal },
403
+ callback: () => Promise<T>,
404
+ ): Promise<T>
405
+ }
406
+
407
+ /** In-realm fallback when Web Locks are unavailable: one chain per token storage. */
408
+ const inProcessLocks = new WeakMap<object, Promise<unknown>>()
409
+
410
+ function getWebLocks(): WebLockManager | null {
411
+ const nav = (globalThis as { navigator?: { locks?: unknown } }).navigator
412
+ const locks = nav?.locks
413
+ if (typeof locks !== 'object' || locks === null) return null
414
+ return typeof (locks as { request?: unknown }).request === 'function'
415
+ ? (locks as WebLockManager)
416
+ : null
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Simple token storage backed by localStorage (browser) or in-memory fallback
421
+ // ---------------------------------------------------------------------------
422
+
423
+ export interface AuthTokenStorage {
424
+ getAccessToken(): MaybePromise<string | null>
425
+ getRefreshToken(): MaybePromise<string | null>
426
+ setTokens(access: string, refresh: string): MaybePromise<void>
427
+ clear(): MaybePromise<void>
428
+ }
429
+
430
+ function createTokenStorage(prefix: string): AuthTokenStorage {
431
+ // Try localStorage; fall back to in-memory if unavailable (SSR, Web Worker, etc.)
432
+ let useLocalStorage = false
433
+ try {
434
+ if (typeof window !== 'undefined' && typeof window.localStorage !== 'undefined') {
435
+ // Smoke test: ensure we can actually write
436
+ const testKey = `${prefix}_test`
437
+ window.localStorage.setItem(testKey, '1')
438
+ window.localStorage.removeItem(testKey)
439
+ useLocalStorage = true
440
+ }
441
+ } catch {
442
+ // localStorage not available (e.g., Safari private browsing throws in some contexts)
443
+ }
444
+
445
+ if (useLocalStorage) {
446
+ const accessKey = `${prefix}_access_token`
447
+ const refreshKey = `${prefix}_refresh_token`
448
+ return {
449
+ getAccessToken(): string | null {
450
+ return window.localStorage.getItem(accessKey)
451
+ },
452
+ getRefreshToken(): string | null {
453
+ return window.localStorage.getItem(refreshKey)
454
+ },
455
+ setTokens(access: string, refresh: string): void {
456
+ window.localStorage.setItem(accessKey, access)
457
+ window.localStorage.setItem(refreshKey, refresh)
458
+ },
459
+ clear(): void {
460
+ window.localStorage.removeItem(accessKey)
461
+ window.localStorage.removeItem(refreshKey)
462
+ },
463
+ }
464
+ }
465
+
466
+ // In-memory fallback
467
+ let accessToken: string | null = null
468
+ let refreshToken: string | null = null
469
+ return {
470
+ getAccessToken(): string | null {
471
+ return accessToken
472
+ },
473
+ getRefreshToken(): string | null {
474
+ return refreshToken
475
+ },
476
+ setTokens(access: string, refresh: string): void {
477
+ accessToken = access
478
+ refreshToken = refresh
479
+ },
480
+ clear(): void {
481
+ accessToken = null
482
+ refreshToken = null
483
+ },
484
+ }
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // AuthClient
489
+ // ---------------------------------------------------------------------------
490
+
491
+ /**
492
+ * Client-side authentication manager for Kora.js.
493
+ *
494
+ * Manages token storage, session restoration, sign-up, sign-in, sign-out,
495
+ * token refresh, and auth state change notifications. Framework-agnostic --
496
+ * works in any JavaScript environment with `fetch` and optionally `localStorage`.
497
+ *
498
+ * Offline-first session rules (AUTH-13):
499
+ * - Only the auth server ends a session: tokens are cleared only on a 401 (or a
500
+ * 400 `invalid_grant`) carrying a Kora JSON error, on an explicit sign-out, or
501
+ * when the refresh token itself has expired.
502
+ * - Every other failure (no network, timeout, abort, 5xx, 429, 511, HTML from a
503
+ * captive portal) keeps the tokens, keeps the user signed in as
504
+ * authenticated-offline and retries with jittered backoff.
505
+ * - One tab refreshes at a time (Web Locks); the others adopt its result.
506
+ *
507
+ * @example
508
+ * ```typescript
509
+ * const auth = new AuthClient({ serverUrl: 'http://localhost:3001' })
510
+ * await auth.initialize()
511
+ *
512
+ * if (!auth.isAuthenticated) {
513
+ * await auth.signIn({ email: 'user@example.com', password: 'secret' })
514
+ * }
515
+ *
516
+ * const unsub = auth.onAuthChange((state) => {
517
+ * console.log('Auth state:', state)
518
+ * })
519
+ * ```
520
+ */
521
+ export class AuthClient {
522
+ private readonly serverUrl: string
523
+ private readonly storage: AuthTokenStorage
524
+ private readonly fetchFn: typeof fetch
525
+ private readonly deviceIdentity: AuthDeviceIdentityProvider | undefined
526
+ private readonly listeners: Set<(state: AuthState) => void> = new Set()
527
+ private readonly sessionListeners: Set<(session: AuthClientSession | null) => void> = new Set()
528
+ private readonly requestTimeoutMs: number
529
+ private readonly maxOfflineGraceMs: number | undefined
530
+ private readonly backoffBaseMs: number
531
+ private readonly backoffMaxMs: number
532
+ private readonly lockName: string
533
+
534
+ private _state: AuthState = 'loading'
535
+ private _user: AuthUser | null = null
536
+ private _refreshPromise: Promise<RefreshOutcome> | null = null
537
+ private _initialized = false
538
+
539
+ private sessionStatus: AuthSessionStatus = 'fresh'
540
+ private lastServerContactAt = 0
541
+ private failureCount = 0
542
+ private nextAttemptAt = 0
543
+ private retryTimer: ReturnType<typeof setTimeout> | null = null
544
+ private readonly detachEnvironment: () => void
545
+
546
+ /**
547
+ * Creates a new AuthClient.
548
+ *
549
+ * @param config - Auth client configuration
550
+ */
551
+ constructor(config: AuthClientConfig) {
552
+ // Strip trailing slash to normalize URLs
553
+ this.serverUrl = config.serverUrl.replace(/\/+$/, '')
554
+ const prefix = config.storageKey ?? 'kora_auth'
555
+ this.storage = config.storage ?? createTokenStorage(prefix)
556
+ this.fetchFn = config.fetch ?? getDefaultFetch()
557
+ this.deviceIdentity = config.deviceIdentity
558
+ this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
559
+ this.maxOfflineGraceMs = config.maxOfflineGraceMs
560
+ this.backoffBaseMs = config.refreshBackoff?.baseDelayMs ?? DEFAULT_BACKOFF_BASE_MS
561
+ this.backoffMaxMs = config.refreshBackoff?.maxDelayMs ?? DEFAULT_BACKOFF_MAX_MS
562
+ this.lockName = `kora-auth-refresh:${prefix}`
563
+ this.detachEnvironment = this.attachEnvironmentListeners()
564
+ }
565
+
566
+ // -----------------------------------------------------------------------
567
+ // Public getters
568
+ // -----------------------------------------------------------------------
569
+
570
+ /** Current authentication state. */
571
+ get state(): AuthState {
572
+ return this._state
573
+ }
574
+
575
+ /** Current authenticated user, or null if not signed in. */
576
+ get currentUser(): AuthUser | null {
577
+ return this._user
578
+ }
579
+
580
+ /** Whether the user is currently authenticated (fresh or offline). */
581
+ get isAuthenticated(): boolean {
582
+ return this._state === 'authenticated'
583
+ }
584
+
585
+ /**
586
+ * The stored session identity and its freshness, or null when signed out.
587
+ * Unlike {@link getAccessToken}, this is available offline: the identity is
588
+ * decoupled from whether a fresh access token can be minted right now.
589
+ */
590
+ get session(): AuthClientSession | null {
591
+ if (this._state !== 'authenticated' || !this._user) return null
592
+ return {
593
+ userId: this._user.id,
594
+ deviceId: this.cachedDeviceId,
595
+ status: this.sessionStatus,
596
+ lastServerContactAt: this.lastServerContactAt,
597
+ }
598
+ }
599
+
600
+ private cachedDeviceId: string | null = null
601
+
602
+ /**
603
+ * Read the stored session identity (user id and device id) from storage,
604
+ * without any network request. Returns null when no usable session is stored.
605
+ */
606
+ async getStoredIdentity(): Promise<{ userId: string; deviceId: string | null } | null> {
607
+ const claims = await this.getStoredClaims()
608
+ if (!claims || typeof claims.sub !== 'string' || claims.sub.length === 0) return null
609
+ return {
610
+ userId: claims.sub,
611
+ deviceId: typeof claims.dev === 'string' && claims.dev.length > 0 ? claims.dev : null,
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Decoded (unverified) claims of the stored credentials, preferring the access
617
+ * token even when it has expired. For client-side hints only (local database
618
+ * name, sync node id, handshake scope narrowing); the server re-derives
619
+ * everything it authorizes from a verified token.
620
+ */
621
+ async getStoredClaims(): Promise<Record<string, unknown> | null> {
622
+ const access = await this.storage.getAccessToken()
623
+ const refresh = await this.storage.getRefreshToken()
624
+ if (!refresh) return null
625
+ for (const token of [access, refresh]) {
626
+ if (!token) continue
627
+ const claims = decodeJwtPayload(token)
628
+ if (claims && typeof claims.sub === 'string' && claims.sub.length > 0) return claims
629
+ }
630
+ return null
631
+ }
632
+
633
+ // -----------------------------------------------------------------------
634
+ // Initialization
635
+ // -----------------------------------------------------------------------
636
+
637
+ /**
638
+ * Initialize the auth client by restoring a session from stored tokens.
639
+ *
640
+ * Loads tokens from storage, validates the access token, and attempts a
641
+ * refresh if the access token is expired but a refresh token is available.
642
+ * When the auth server cannot be reached, the stored session is restored as
643
+ * authenticated-offline instead of being discarded.
644
+ * Safe to call multiple times -- subsequent calls are no-ops once initialized.
645
+ */
646
+ async initialize(): Promise<void> {
647
+ // Guard against double initialization (e.g., React StrictMode double-mount)
648
+ if (this._initialized) {
649
+ return
650
+ }
651
+ this._initialized = true
652
+
653
+ const accessToken = await this.storage.getAccessToken()
654
+ const refreshToken = await this.storage.getRefreshToken()
655
+
656
+ // No stored tokens -- stay unauthenticated
657
+ if (!accessToken || !refreshToken) {
658
+ this.setState('unauthenticated', null)
659
+ return
660
+ }
661
+ this.noteIssuedCredential(refreshToken)
662
+
663
+ // Access token still valid -- restore session from it
664
+ if (!isTokenExpired(accessToken)) {
665
+ await this.restoreSession(accessToken)
666
+ return
667
+ }
668
+
669
+ // Access token expired -- try refreshing
670
+ const outcome = await this.refresh()
671
+ if (outcome.kind === 'ok') {
672
+ await this.restoreSession(outcome.accessToken)
673
+ return
674
+ }
675
+ if (outcome.kind === 'rejected') {
676
+ // refresh() already cleared tokens and moved to unauthenticated.
677
+ this.setState('unauthenticated', null)
678
+ return
679
+ }
680
+ await this.enterOfflineSession()
681
+ }
682
+
683
+ // -----------------------------------------------------------------------
684
+ // Sign up / Sign in / Sign out
685
+ // -----------------------------------------------------------------------
686
+
687
+ /**
688
+ * Register a new user account.
689
+ *
690
+ * @param params - Sign-up credentials
691
+ * @returns The newly created AuthUser
692
+ * @throws {AuthError} If the request fails or the server returns an error
693
+ */
694
+ async signUp(params: {
695
+ email: string
696
+ password: string
697
+ name?: string
698
+ deviceId?: string
699
+ devicePublicKey?: string
700
+ }): Promise<AuthUser> {
701
+ const body = await this.withDeviceIdentity(params)
702
+ const response = await this.request<AuthSignInResponse | AuthTokensResponse>('/auth/signup', {
703
+ method: 'POST',
704
+ body,
705
+ })
706
+ return this.completeSignIn(response)
707
+ }
708
+
709
+ /**
710
+ * Sign in with email and password.
711
+ *
712
+ * @param params - Sign-in credentials
713
+ * @returns The authenticated AuthUser
714
+ * @throws {AuthError} If the credentials are invalid or the request fails
715
+ */
716
+ async signIn(params: {
717
+ email: string
718
+ password: string
719
+ deviceId?: string
720
+ devicePublicKey?: string
721
+ }): Promise<AuthUser> {
722
+ const body = await this.withDeviceIdentity(params)
723
+ const response = await this.request<
724
+ AuthSignInResponse | AuthTokensResponse | MfaChallengeResponse
725
+ >('/auth/signin', {
726
+ method: 'POST',
727
+ body,
728
+ })
729
+ return this.completeSignIn(response)
730
+ }
731
+
732
+ /**
733
+ * Finish a sign-in that required a second factor.
734
+ *
735
+ * @param mfaToken - From the {@link MfaRequiredError} thrown by sign-in
736
+ * @param proof - A current TOTP code, or a recovery code
737
+ * @returns The authenticated AuthUser
738
+ * @throws {AuthError} If the code or the MFA session is invalid
739
+ */
740
+ async verifyMfa(
741
+ mfaToken: string,
742
+ proof: { code: string } | { recoveryCode: string },
743
+ ): Promise<AuthUser> {
744
+ const response = await this.request<AuthSignInResponse>('/auth/mfa/verify', {
745
+ method: 'POST',
746
+ body: { mfaToken, ...proof },
747
+ })
748
+ return this.completeSignIn(response)
749
+ }
750
+
751
+ /**
752
+ * Create an OAuth authorization URL and optionally redirect the current window.
753
+ *
754
+ * For web apps, call this from a button click and keep the default redirect behavior.
755
+ * For desktop/mobile, pass `redirect: false`, open the returned URL with the runtime's
756
+ * browser API, then call `completeOAuthSignIn()` after receiving the callback.
757
+ */
758
+ async signInWithOAuth(
759
+ provider: string,
760
+ options: OAuthAuthorizationOptions = {},
761
+ ): Promise<OAuthAuthorizationResult> {
762
+ const result = await this.createOAuthAuthorization(provider, options)
763
+ if (options.redirect ?? canRedirectCurrentWindow()) {
764
+ redirectCurrentWindow(result.url)
765
+ }
766
+ return result
767
+ }
768
+
769
+ /**
770
+ * Complete an OAuth sign-in callback and store the issued Kora tokens.
771
+ */
772
+ async completeOAuthSignIn(provider: string, params: OAuthCallbackParams): Promise<AuthUser> {
773
+ const body = await this.withDeviceIdentity(params)
774
+ const binding = params.binding ?? this.takeOAuthBinding(params.state)
775
+ const response = await this.request<OAuthSignInResponse | MfaChallengeResponse>(
776
+ `/auth/oauth/${encodeURIComponent(provider)}/callback`,
777
+ {
778
+ method: 'POST',
779
+ body: { ...body, ...(binding ? { binding } : {}) },
780
+ },
781
+ )
782
+ if ('mfaRequired' in response) {
783
+ throw new MfaRequiredError(response.mfaToken)
784
+ }
785
+
786
+ await this.storage.setTokens(response.tokens.accessToken, response.tokens.refreshToken)
787
+ this.markFresh(response.tokens.refreshToken)
788
+ const user = normalizeAuthUser(response.user)
789
+ this.setState('authenticated', user)
790
+ return user
791
+ }
792
+
793
+ /**
794
+ * Create an OAuth authorization URL for linking another provider to the current user.
795
+ */
796
+ async getOAuthAuthorizationUrl(
797
+ provider: string,
798
+ _options: OAuthAuthorizationOptions = {},
799
+ ): Promise<OAuthAuthorizationResult> {
800
+ // Linking starts from an authenticated endpoint so the state is bound to
801
+ // this user and can never be redeemed by anyone else (AUTH-3).
802
+ const token = await this.requireAccessToken()
803
+ const result = await this.request<OAuthAuthorizationResult>(
804
+ `/auth/oauth/${encodeURIComponent(provider)}/link/start`,
805
+ { method: 'POST', body: {}, token },
806
+ )
807
+ this.rememberOAuthBinding(result)
808
+ return result
809
+ }
810
+
811
+ /**
812
+ * Link an OAuth provider to the current authenticated user.
813
+ */
814
+ async linkOAuth(provider: string, params: OAuthCallbackParams): Promise<LinkedOAuthAccount> {
815
+ const token = await this.requireAccessToken()
816
+ const binding = params.binding ?? this.takeOAuthBinding(params.state)
817
+ return this.request<LinkedOAuthAccount>(`/auth/oauth/${encodeURIComponent(provider)}/link`, {
818
+ method: 'POST',
819
+ body: {
820
+ code: params.code,
821
+ state: params.state,
822
+ ...(binding ? { binding } : {}),
823
+ },
824
+ token,
825
+ })
826
+ }
827
+
828
+ /**
829
+ * List OAuth accounts linked to the current authenticated user.
830
+ */
831
+ async listLinkedAccounts(): Promise<LinkedOAuthAccount[]> {
832
+ const token = await this.requireAccessToken()
833
+ return this.request<LinkedOAuthAccount[]>('/auth/oauth/links', {
834
+ method: 'GET',
835
+ token,
836
+ })
837
+ }
838
+
839
+ /**
840
+ * Unlink an OAuth provider from the current authenticated user.
841
+ */
842
+ async unlinkOAuth(provider: string): Promise<void> {
843
+ const token = await this.requireAccessToken()
844
+ await this.request<{ ok: true }>(`/auth/oauth/${encodeURIComponent(provider)}/link`, {
845
+ method: 'DELETE',
846
+ token,
847
+ })
848
+ }
849
+
850
+ /**
851
+ * Sign out the current user.
852
+ *
853
+ * Clears local tokens and attempts to revoke the refresh token on the server
854
+ * (best-effort — succeeds even if the server is unreachable). This ensures that
855
+ * stolen refresh tokens cannot be used after the user explicitly signs out.
856
+ */
857
+ async signOut(): Promise<void> {
858
+ const accessToken = await this.storage.getAccessToken()
859
+ const refreshToken = await this.storage.getRefreshToken()
860
+
861
+ // Clear local state immediately (don't wait for server)
862
+ await this.storage.clear()
863
+ this._refreshPromise = null
864
+ this.resetBackoff()
865
+ this.sessionStatus = 'fresh'
866
+ this.cachedDeviceId = null
867
+ this.setState('unauthenticated', null)
868
+
869
+ // Best-effort server-side revocation
870
+ if (accessToken) {
871
+ try {
872
+ await this.request('/auth/signout', {
873
+ method: 'POST',
874
+ body: { refreshToken: refreshToken ?? undefined },
875
+ token: accessToken,
876
+ })
877
+ } catch {
878
+ // Server may be unreachable (offline) — local sign-out still succeeds
879
+ }
880
+ }
881
+ }
882
+
883
+ // -----------------------------------------------------------------------
884
+ // Token access
885
+ // -----------------------------------------------------------------------
886
+
887
+ /**
888
+ * Get a valid access token, automatically refreshing if expired.
889
+ *
890
+ * Returns null when no fresh token can be obtained right now. That does NOT
891
+ * mean the user is signed out: check {@link state} / {@link session}. A
892
+ * transient failure keeps the session (authenticated-offline) and later calls
893
+ * retry with backoff.
894
+ *
895
+ * @returns A valid access token string, or null if none is available now
896
+ */
897
+ async getAccessToken(): Promise<string | null> {
898
+ const accessToken = await this.storage.getAccessToken()
899
+
900
+ if (accessToken && !isTokenExpired(accessToken)) {
901
+ if (this._state === 'authenticated' && this.sessionStatus !== 'fresh') {
902
+ // Another tab refreshed while this one was offline.
903
+ this.markFresh(await this.storage.getRefreshToken())
904
+ }
905
+ return accessToken
906
+ }
907
+
908
+ const refreshToken = await this.storage.getRefreshToken()
909
+ if (!refreshToken) {
910
+ return null
911
+ }
912
+
913
+ const outcome = await this.refresh()
914
+ return outcome.kind === 'ok' ? outcome.accessToken : null
915
+ }
916
+
917
+ /**
918
+ * Refresh the session now, even when the cached access token has not expired
919
+ * locally. Used after the sync server ended a session with `AUTH_EXPIRED` or
920
+ * `AUTH_REVOKED`: the server's clock or a revocation says the cached token is
921
+ * no longer good. Concurrent calls (and other tabs) share one refresh.
922
+ *
923
+ * A transient failure keeps the session (authenticated-offline) and returns
924
+ * null; only a definitive server rejection signs the user out.
925
+ *
926
+ * @returns The refreshed access token, or null if none could be obtained now
927
+ */
928
+ async refreshAccessToken(): Promise<string | null> {
929
+ const refreshToken = await this.storage.getRefreshToken()
930
+ if (!refreshToken) {
931
+ return null
932
+ }
933
+ const outcome = await this.refresh()
934
+ return outcome.kind === 'ok' ? outcome.accessToken : null
935
+ }
936
+
937
+ /**
938
+ * Get a valid token for the sync engine handshake.
939
+ * Alias for {@link getAccessToken}.
940
+ *
941
+ * @returns A valid access token string, or null if unavailable
942
+ */
943
+ async getSyncToken(): Promise<string | null> {
944
+ return this.getAccessToken()
945
+ }
946
+
947
+ /**
948
+ * Retry immediately: clears the refresh backoff and attempts a refresh if the
949
+ * session is offline. Call it when connectivity is known to be back (for
950
+ * example when a sync transport opens). Also wired to the browser `online`
951
+ * and `visibilitychange` events automatically.
952
+ */
953
+ async retryNow(): Promise<void> {
954
+ this.resetBackoff()
955
+ if (this._state === 'authenticated' && this.sessionStatus !== 'fresh') {
956
+ await this.getAccessToken()
957
+ }
958
+ }
959
+
960
+ /** Remove environment listeners and timers (for tests and teardown). */
961
+ destroy(): void {
962
+ this.detachEnvironment()
963
+ this.clearRetryTimer()
964
+ }
965
+
966
+ // -----------------------------------------------------------------------
967
+ // State change subscriptions
968
+ // -----------------------------------------------------------------------
969
+
970
+ /**
971
+ * Subscribe to authentication state changes.
972
+ *
973
+ * The callback is invoked whenever the auth state transitions (e.g., from
974
+ * 'unauthenticated' to 'authenticated' on sign-in).
975
+ *
976
+ * @param callback - Function called with the new AuthState on each change
977
+ * @returns An unsubscribe function that removes the listener
978
+ *
979
+ * @example
980
+ * ```typescript
981
+ * const unsub = auth.onAuthChange((state) => {
982
+ * console.log('Auth state changed to:', state)
983
+ * })
984
+ * // Later: unsub()
985
+ * ```
986
+ */
987
+ onAuthChange(callback: (state: AuthState) => void): () => void {
988
+ this.listeners.add(callback)
989
+ return () => {
990
+ this.listeners.delete(callback)
991
+ }
992
+ }
993
+
994
+ /**
995
+ * Subscribe to session freshness changes (fresh, authenticated-offline,
996
+ * locked). Fires in addition to {@link onAuthChange}, including when the
997
+ * state stays 'authenticated' but connectivity to the auth server changes.
998
+ *
999
+ * @param callback - Called with the current session (null when signed out)
1000
+ * @returns An unsubscribe function
1001
+ */
1002
+ onSessionChange(callback: (session: AuthClientSession | null) => void): () => void {
1003
+ this.sessionListeners.add(callback)
1004
+ return () => {
1005
+ this.sessionListeners.delete(callback)
1006
+ }
1007
+ }
1008
+
1009
+ // -----------------------------------------------------------------------
1010
+ // Internal helpers
1011
+ // -----------------------------------------------------------------------
1012
+
1013
+ /**
1014
+ * Update internal state and notify all listeners.
1015
+ */
1016
+ private setState(state: AuthState, user: AuthUser | null): void {
1017
+ const changed = this._state !== state || this._user !== user
1018
+ this._state = state
1019
+ this._user = user
1020
+
1021
+ if (changed) {
1022
+ for (const listener of this.listeners) {
1023
+ try {
1024
+ listener(state)
1025
+ } catch {
1026
+ // Listeners should not throw, but if they do, do not let it
1027
+ // break the notification loop for other listeners.
1028
+ }
1029
+ }
1030
+ this.notifySession()
1031
+ }
1032
+ }
1033
+
1034
+ private setSessionStatus(status: AuthSessionStatus): void {
1035
+ if (this.sessionStatus === status) return
1036
+ this.sessionStatus = status
1037
+ this.notifySession()
1038
+ }
1039
+
1040
+ private notifySession(): void {
1041
+ const session = this.session
1042
+ for (const listener of this.sessionListeners) {
1043
+ try {
1044
+ listener(session)
1045
+ } catch {
1046
+ // Same isolation as auth listeners.
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ private async completeSignIn(
1052
+ response: AuthSignInResponse | AuthTokensResponse | MfaChallengeResponse,
1053
+ ): Promise<AuthUser> {
1054
+ if ('mfaRequired' in response) {
1055
+ throw new MfaRequiredError(response.mfaToken)
1056
+ }
1057
+ const tokens = 'tokens' in response ? response.tokens : response
1058
+ await this.storage.setTokens(tokens.accessToken, tokens.refreshToken)
1059
+ this.markFresh(tokens.refreshToken)
1060
+ const user =
1061
+ 'user' in response && response.user
1062
+ ? normalizeAuthUser(response.user)
1063
+ : await this.fetchUserProfile(tokens.accessToken)
1064
+ this.setState('authenticated', user)
1065
+ return user
1066
+ }
1067
+
1068
+ /**
1069
+ * Restore a session from a valid access token by fetching the user profile.
1070
+ * A definitive 401 from `/auth/me` ends the session (NEW-AUTH-4); any other
1071
+ * failure restores it as authenticated-offline from the stored identity.
1072
+ */
1073
+ private async restoreSession(accessToken: string): Promise<void> {
1074
+ let response: RawResponse
1075
+ try {
1076
+ response = await this.rawRequest('/auth/me', { method: 'GET', token: accessToken })
1077
+ } catch {
1078
+ await this.enterOfflineSession()
1079
+ return
1080
+ }
1081
+ if (response.ok && isRecord(response.json)) {
1082
+ const profile = (response.json.data !== undefined ? response.json.data : response.json) as
1083
+ | UserProfileResponse
1084
+ | undefined
1085
+ if (isRecord(profile) && typeof profile.id === 'string') {
1086
+ this.markFresh(null)
1087
+ this.setState('authenticated', normalizeAuthUser(profile))
1088
+ return
1089
+ }
1090
+ }
1091
+ if (response.status === 401 && isDefinitiveRejection(response)) {
1092
+ await this.endSession()
1093
+ return
1094
+ }
1095
+ await this.enterOfflineSession()
1096
+ }
1097
+
1098
+ /**
1099
+ * Restore the session from stored credentials while the auth server is
1100
+ * unreachable. Signs out only when the stored refresh token is unusable.
1101
+ */
1102
+ private async enterOfflineSession(): Promise<void> {
1103
+ const refreshToken = await this.storage.getRefreshToken()
1104
+ const identity = await this.getStoredIdentity()
1105
+ if (!refreshToken || !identity || isTokenExpired(refreshToken, 0)) {
1106
+ await this.endSession()
1107
+ return
1108
+ }
1109
+ this.cachedDeviceId = identity.deviceId
1110
+ this.noteIssuedCredential(refreshToken)
1111
+ const user =
1112
+ this._user && this._user.id === identity.userId
1113
+ ? this._user
1114
+ : { id: identity.userId, email: '', name: null }
1115
+ this.sessionStatus = this.offlineStatus()
1116
+ this.setState('authenticated', user)
1117
+ this.notifySession()
1118
+ }
1119
+
1120
+ /** Offline, or locked when the grace period ran out or the clock went backwards. */
1121
+ private offlineStatus(): AuthSessionStatus {
1122
+ const now = Date.now()
1123
+ if (
1124
+ this.lastServerContactAt > 0 &&
1125
+ now + CLOCK_ROLLBACK_TOLERANCE_MS < this.lastServerContactAt
1126
+ ) {
1127
+ // The device clock is earlier than a moment we know already happened:
1128
+ // it was set back, which would otherwise extend the offline grace.
1129
+ return 'locked'
1130
+ }
1131
+ if (
1132
+ this.maxOfflineGraceMs !== undefined &&
1133
+ this.lastServerContactAt > 0 &&
1134
+ now - this.lastServerContactAt > this.maxOfflineGraceMs
1135
+ ) {
1136
+ return 'locked'
1137
+ }
1138
+ return 'offline'
1139
+ }
1140
+
1141
+ private async endSession(): Promise<void> {
1142
+ await this.storage.clear()
1143
+ this.resetBackoff()
1144
+ this.sessionStatus = 'fresh'
1145
+ this.cachedDeviceId = null
1146
+ this.setState('unauthenticated', null)
1147
+ }
1148
+
1149
+ /** The refresh token's issue time is the server's clock at the last rotation. */
1150
+ private noteIssuedCredential(refreshToken: string | null): void {
1151
+ if (!refreshToken) return
1152
+ const issued = tokenIssuedAtMs(refreshToken)
1153
+ if (issued !== null) this.lastServerContactAt = Math.max(this.lastServerContactAt, issued)
1154
+ const claims = decodeJwtPayload(refreshToken)
1155
+ if (claims && typeof claims.dev === 'string') this.cachedDeviceId = claims.dev
1156
+ }
1157
+
1158
+ private markFresh(refreshToken: string | null): void {
1159
+ this.resetBackoff()
1160
+ this.lastServerContactAt = Math.max(this.lastServerContactAt, Date.now())
1161
+ this.noteIssuedCredential(refreshToken)
1162
+ this.setSessionStatus('fresh')
1163
+ }
1164
+
1165
+ private markOffline(): void {
1166
+ if (this._state === 'authenticated') {
1167
+ this.setSessionStatus(this.offlineStatus())
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Fetch the current user profile from the server.
1173
+ */
1174
+ private async fetchUserProfile(accessToken: string): Promise<AuthUser> {
1175
+ const profile = await this.request<UserProfileResponse>('/auth/me', {
1176
+ method: 'GET',
1177
+ token: accessToken,
1178
+ })
1179
+ return normalizeAuthUser(profile)
1180
+ }
1181
+
1182
+ private async createOAuthAuthorization(
1183
+ provider: string,
1184
+ options: OAuthAuthorizationOptions,
1185
+ ): Promise<OAuthAuthorizationResult> {
1186
+ const params = new URLSearchParams()
1187
+
1188
+ if (options.returnTo) {
1189
+ params.set('returnTo', options.returnTo)
1190
+ }
1191
+ if (options.metadata) {
1192
+ for (const [key, value] of Object.entries(options.metadata)) {
1193
+ if (value !== undefined && value !== null) {
1194
+ params.set(key, String(value))
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ const query = params.toString()
1200
+ const result = await this.request<OAuthAuthorizationResult>(
1201
+ `/auth/oauth/${encodeURIComponent(provider)}${query ? `?${query}` : ''}`,
1202
+ {
1203
+ method: 'GET',
1204
+ },
1205
+ )
1206
+ this.rememberOAuthBinding(result)
1207
+ return result
1208
+ }
1209
+
1210
+ /** Bindings of flows this client started, by state (survives the redirect on web). */
1211
+ private readonly oauthBindings = new Map<string, string>()
1212
+
1213
+ private rememberOAuthBinding(result: OAuthAuthorizationResult): void {
1214
+ if (!result.binding || !result.state) return
1215
+ this.oauthBindings.set(result.state, result.binding)
1216
+ const session = getSessionStorage()
1217
+ try {
1218
+ session?.setItem(`${OAUTH_BINDING_PREFIX}${result.state}`, result.binding)
1219
+ } catch {
1220
+ // Session storage full or blocked: the in-memory copy still serves native flows.
1221
+ }
1222
+ }
1223
+
1224
+ private takeOAuthBinding(state: string): string | undefined {
1225
+ const inMemory = this.oauthBindings.get(state)
1226
+ this.oauthBindings.delete(state)
1227
+ const session = getSessionStorage()
1228
+ let stored: string | null = null
1229
+ try {
1230
+ stored = session?.getItem(`${OAUTH_BINDING_PREFIX}${state}`) ?? null
1231
+ session?.removeItem(`${OAUTH_BINDING_PREFIX}${state}`)
1232
+ } catch {
1233
+ stored = null
1234
+ }
1235
+ return inMemory ?? stored ?? undefined
1236
+ }
1237
+
1238
+ private async withDeviceIdentity<T extends { deviceId?: string; devicePublicKey?: string }>(
1239
+ params: T,
1240
+ ): Promise<T> {
1241
+ if (!this.deviceIdentity || (params.deviceId && params.devicePublicKey)) {
1242
+ return params
1243
+ }
1244
+
1245
+ const identity = await this.deviceIdentity.getDeviceIdentity()
1246
+ return {
1247
+ ...params,
1248
+ deviceId: params.deviceId ?? identity.deviceId,
1249
+ devicePublicKey: params.devicePublicKey ?? identity.devicePublicKey,
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * Refresh the session's tokens. De-duplicates concurrent calls in this
1255
+ * client, serializes refreshes across tabs, and applies backoff after
1256
+ * transient failures.
1257
+ */
1258
+ private refresh(): Promise<RefreshOutcome> {
1259
+ if (this._refreshPromise) {
1260
+ return this._refreshPromise
1261
+ }
1262
+ const promise = this.refreshOnce().finally(() => {
1263
+ if (this._refreshPromise === promise) this._refreshPromise = null
1264
+ })
1265
+ this._refreshPromise = promise
1266
+ return promise
1267
+ }
1268
+
1269
+ private async refreshOnce(): Promise<RefreshOutcome> {
1270
+ const refreshToken = await this.storage.getRefreshToken()
1271
+ if (!refreshToken) return { kind: 'rejected' }
1272
+
1273
+ // A refresh token that has expired is unusable whatever the network says.
1274
+ if (isTokenExpired(refreshToken, 0)) {
1275
+ await this.endSession()
1276
+ return { kind: 'rejected' }
1277
+ }
1278
+
1279
+ if (Date.now() < this.nextAttemptAt) {
1280
+ this.markOffline()
1281
+ return { kind: 'transient' }
1282
+ }
1283
+
1284
+ let outcome: RefreshOutcome
1285
+ try {
1286
+ outcome = await this.withRefreshLock(async () => {
1287
+ // Another tab may have refreshed while we waited for the lock: adopt
1288
+ // its tokens instead of presenting a now-rotated refresh token.
1289
+ const current = await this.storage.getRefreshToken()
1290
+ if (!current) return { kind: 'rejected' } as const
1291
+ const currentAccess = await this.storage.getAccessToken()
1292
+ if (current !== refreshToken && currentAccess && !isTokenExpired(currentAccess)) {
1293
+ return { kind: 'ok', accessToken: currentAccess } as const
1294
+ }
1295
+ return this.performRefresh(current)
1296
+ })
1297
+ } catch {
1298
+ // Lock acquisition timed out (a hung tab holds it): transient.
1299
+ outcome = { kind: 'transient' }
1300
+ }
1301
+
1302
+ if (outcome.kind === 'ok') {
1303
+ this.markFresh(await this.storage.getRefreshToken())
1304
+ } else if (outcome.kind === 'rejected') {
1305
+ this.resetBackoff()
1306
+ this.cachedDeviceId = null
1307
+ this.sessionStatus = 'fresh'
1308
+ this.setState('unauthenticated', null)
1309
+ } else {
1310
+ this.registerFailure(outcome.retryAfterMs)
1311
+ this.markOffline()
1312
+ }
1313
+ return outcome
1314
+ }
1315
+
1316
+ /**
1317
+ * Execute the token refresh network request and classify the answer.
1318
+ */
1319
+ private async performRefresh(refreshToken: string): Promise<RefreshOutcome> {
1320
+ let response: RawResponse
1321
+ try {
1322
+ response = await this.rawRequest('/auth/refresh', {
1323
+ method: 'POST',
1324
+ body: { refreshToken },
1325
+ })
1326
+ } catch {
1327
+ // No network, DNS, TLS, CORS, abort or timeout: says nothing about the token.
1328
+ return { kind: 'transient' }
1329
+ }
1330
+
1331
+ if (response.ok) {
1332
+ const tokens = readTokenPair(response.json)
1333
+ if (!tokens) {
1334
+ // 2xx without tokens: a captive portal or proxy answered, not Kora.
1335
+ return { kind: 'transient' }
1336
+ }
1337
+ await this.storage.setTokens(tokens.accessToken, tokens.refreshToken)
1338
+ return { kind: 'ok', accessToken: tokens.accessToken }
1339
+ }
1340
+
1341
+ if (isDefinitiveRejection(response)) {
1342
+ // Never clear a token pair another tab stored after we read ours.
1343
+ if ((await this.storage.getRefreshToken()) === refreshToken) {
1344
+ await this.storage.clear()
1345
+ return { kind: 'rejected' }
1346
+ }
1347
+ const adopted = await this.storage.getAccessToken()
1348
+ return adopted && !isTokenExpired(adopted)
1349
+ ? { kind: 'ok', accessToken: adopted }
1350
+ : { kind: 'transient' }
1351
+ }
1352
+
1353
+ return { kind: 'transient', retryAfterMs: response.retryAfterMs }
1354
+ }
1355
+
1356
+ private async withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
1357
+ const locks = getWebLocks()
1358
+ if (locks) {
1359
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
1360
+ const timer = controller
1361
+ ? setTimeout(() => controller.abort(), this.requestTimeoutMs * 2)
1362
+ : null
1363
+ try {
1364
+ return await locks.request(
1365
+ this.lockName,
1366
+ controller ? { signal: controller.signal } : {},
1367
+ async () => {
1368
+ if (timer) clearTimeout(timer)
1369
+ return fn()
1370
+ },
1371
+ )
1372
+ } finally {
1373
+ if (timer) clearTimeout(timer)
1374
+ }
1375
+ }
1376
+ // Same-realm fallback (Node, older browsers): serialize per storage object.
1377
+ const key = this.storage as object
1378
+ const previous = inProcessLocks.get(key) ?? Promise.resolve()
1379
+ const run = previous.then(fn, fn)
1380
+ inProcessLocks.set(
1381
+ key,
1382
+ run.then(
1383
+ () => undefined,
1384
+ () => undefined,
1385
+ ),
1386
+ )
1387
+ return run
1388
+ }
1389
+
1390
+ /**
1391
+ * Schedule the next allowed refresh attempt. The first retry after a failure
1392
+ * is immediate (it recovers a rotation response lost on the wire); after
1393
+ * that, exponential backoff with equal jitter, never earlier than Retry-After.
1394
+ */
1395
+ private registerFailure(retryAfterMs: number | undefined): void {
1396
+ this.failureCount++
1397
+ let delay = 0
1398
+ if (this.failureCount > 1) {
1399
+ const ceiling = Math.min(this.backoffBaseMs * 2 ** (this.failureCount - 2), this.backoffMaxMs)
1400
+ delay = ceiling / 2 + Math.random() * (ceiling / 2)
1401
+ }
1402
+ if (retryAfterMs !== undefined) delay = Math.max(delay, retryAfterMs)
1403
+ this.nextAttemptAt = Date.now() + delay
1404
+ this.scheduleRetry(Math.max(delay, this.backoffBaseMs))
1405
+ }
1406
+
1407
+ private resetBackoff(): void {
1408
+ this.failureCount = 0
1409
+ this.nextAttemptAt = 0
1410
+ this.clearRetryTimer()
1411
+ }
1412
+
1413
+ /** Background retry so an offline session recovers without the app calling in. */
1414
+ private scheduleRetry(delayMs: number): void {
1415
+ this.clearRetryTimer()
1416
+ if (typeof setTimeout !== 'function') return
1417
+ const timer = setTimeout(() => {
1418
+ this.retryTimer = null
1419
+ if (this._state === 'authenticated' && this.sessionStatus !== 'fresh') {
1420
+ void this.getAccessToken().catch(() => undefined)
1421
+ }
1422
+ }, delayMs)
1423
+ // Never keep a Node process alive just to retry a refresh.
1424
+ ;(timer as { unref?: () => void }).unref?.()
1425
+ this.retryTimer = timer
1426
+ }
1427
+
1428
+ private clearRetryTimer(): void {
1429
+ if (this.retryTimer !== null) {
1430
+ clearTimeout(this.retryTimer)
1431
+ this.retryTimer = null
1432
+ }
1433
+ }
1434
+
1435
+ private attachEnvironmentListeners(): () => void {
1436
+ const target = (globalThis as { window?: unknown }).window as
1437
+ | {
1438
+ addEventListener?: (type: string, listener: () => void) => void
1439
+ removeEventListener?: (type: string, listener: () => void) => void
1440
+ }
1441
+ | undefined
1442
+ if (!target || typeof target.addEventListener !== 'function') return () => {}
1443
+ const wake = (): void => {
1444
+ const doc = (globalThis as { document?: { visibilityState?: string } }).document
1445
+ if (doc?.visibilityState === 'hidden') return
1446
+ void this.retryNow().catch(() => undefined)
1447
+ }
1448
+ target.addEventListener('online', wake)
1449
+ target.addEventListener('visibilitychange', wake)
1450
+ return () => {
1451
+ target.removeEventListener?.('online', wake)
1452
+ target.removeEventListener?.('visibilitychange', wake)
1453
+ }
1454
+ }
1455
+
1456
+ private async requireAccessToken(): Promise<string> {
1457
+ const token = await this.getAccessToken()
1458
+ if (!token) {
1459
+ throw new AuthError('You must be signed in to perform this action.', 'AUTH_REQUIRED')
1460
+ }
1461
+ return token
1462
+ }
1463
+
1464
+ /**
1465
+ * Send one request with a timeout and return status plus parsed JSON (if any).
1466
+ * Throws only for transport failures (network, abort, timeout).
1467
+ */
1468
+ private async rawRequest(
1469
+ path: string,
1470
+ options: {
1471
+ method: 'GET' | 'POST' | 'DELETE'
1472
+ body?: Record<string, unknown>
1473
+ token?: string
1474
+ },
1475
+ ): Promise<RawResponse> {
1476
+ const url = `${this.serverUrl}${path}`
1477
+ const headers: Record<string, string> = {}
1478
+ if (options.body) {
1479
+ headers['Content-Type'] = 'application/json'
1480
+ }
1481
+ if (options.token) {
1482
+ headers.Authorization = `Bearer ${options.token}`
1483
+ }
1484
+
1485
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
1486
+ let timer: ReturnType<typeof setTimeout> | null = null
1487
+ const timeout = new Promise<never>((_, reject) => {
1488
+ timer = setTimeout(() => {
1489
+ controller?.abort()
1490
+ reject(
1491
+ new AuthError(
1492
+ `Request to ${path} timed out after ${this.requestTimeoutMs}ms.`,
1493
+ 'AUTH_TIMEOUT',
1494
+ { path },
1495
+ ),
1496
+ )
1497
+ }, this.requestTimeoutMs)
1498
+ })
1499
+ try {
1500
+ const response = await Promise.race([
1501
+ this.fetchFn(url, {
1502
+ method: options.method,
1503
+ headers,
1504
+ body: options.body ? JSON.stringify(options.body) : undefined,
1505
+ ...(controller ? { signal: controller.signal } : {}),
1506
+ }),
1507
+ timeout,
1508
+ ])
1509
+ let json: unknown
1510
+ try {
1511
+ json = await Promise.race([response.json() as Promise<unknown>, timeout])
1512
+ } catch {
1513
+ json = undefined
1514
+ }
1515
+ const headerGetter = (response as { headers?: { get?: (name: string) => string | null } })
1516
+ .headers
1517
+ return {
1518
+ status: response.status,
1519
+ ok: response.ok,
1520
+ json,
1521
+ retryAfterMs: parseRetryAfter(headerGetter?.get?.('Retry-After')),
1522
+ }
1523
+ } finally {
1524
+ if (timer !== null) clearTimeout(timer)
1525
+ }
1526
+ }
1527
+
1528
+ /**
1529
+ * Make an HTTP request to the auth server.
1530
+ *
1531
+ * @param path - URL path relative to serverUrl (e.g. '/auth/signin')
1532
+ * @param options - Request options
1533
+ * @returns Parsed JSON response body
1534
+ * @throws {AuthError} On network failure, timeout, or non-2xx response
1535
+ */
1536
+ private async request<T>(
1537
+ path: string,
1538
+ options: {
1539
+ method: 'GET' | 'POST' | 'DELETE'
1540
+ body?: Record<string, unknown>
1541
+ token?: string
1542
+ },
1543
+ ): Promise<T> {
1544
+ let response: RawResponse
1545
+ try {
1546
+ response = await this.rawRequest(path, options)
1547
+ } catch (cause) {
1548
+ if (cause instanceof AuthError) throw cause
1549
+ throw new AuthError(
1550
+ `Network request to ${path} failed. The auth server at ${this.serverUrl} may be unreachable. Check your network connection and serverUrl configuration.`,
1551
+ 'AUTH_NETWORK_ERROR',
1552
+ { path, cause: cause instanceof Error ? cause.message : String(cause) },
1553
+ )
1554
+ }
1555
+
1556
+ if (!response.ok) {
1557
+ let errorMessage = `Auth server returned HTTP ${response.status}`
1558
+ let serverError: string | undefined
1559
+ let serverCode: string | undefined
1560
+ if (isRecord(response.json)) {
1561
+ if (typeof response.json.error === 'string') {
1562
+ errorMessage = response.json.error
1563
+ serverError = errorMessage
1564
+ } else if (typeof response.json.message === 'string') {
1565
+ errorMessage = response.json.message
1566
+ serverError = errorMessage
1567
+ }
1568
+ if (typeof response.json.code === 'string') serverCode = response.json.code
1569
+ }
1570
+
1571
+ throw new AuthError(errorMessage, 'AUTH_SERVER_ERROR', {
1572
+ path,
1573
+ status: response.status,
1574
+ serverError,
1575
+ serverCode,
1576
+ })
1577
+ }
1578
+
1579
+ if (!isRecord(response.json) && !Array.isArray(response.json)) {
1580
+ throw new AuthError(
1581
+ `Auth server returned a non-JSON response for ${path}. A captive portal or proxy may be intercepting requests.`,
1582
+ 'AUTH_INVALID_RESPONSE',
1583
+ { path, status: response.status },
1584
+ )
1585
+ }
1586
+ const json = response.json as Record<string, unknown>
1587
+
1588
+ // The BuiltInAuthRoutes server wraps success responses in { data: T }.
1589
+ // Unwrap the envelope so callers get the inner payload directly.
1590
+ return (json.data !== undefined ? json.data : json) as T
1591
+ }
1592
+ }