@korajs/auth 1.0.0-beta.11 → 1.0.0-beta.13
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/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- 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
|
+
}
|