@o/better-auth-utils 0.16.19

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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/cjs/createAuthClient.cjs +251 -0
  4. package/dist/cjs/createAuthClient.native.js +279 -0
  5. package/dist/cjs/createAuthClient.native.js.map +1 -0
  6. package/dist/cjs/createAuthClient.test.cjs +88 -0
  7. package/dist/cjs/createAuthClient.test.native.js +105 -0
  8. package/dist/cjs/createAuthClient.test.native.js.map +1 -0
  9. package/dist/cjs/index.cjs +20 -0
  10. package/dist/cjs/index.native.js +23 -0
  11. package/dist/cjs/index.native.js.map +1 -0
  12. package/dist/cjs/server.cjs +106 -0
  13. package/dist/cjs/server.native.js +228 -0
  14. package/dist/cjs/server.native.js.map +1 -0
  15. package/dist/esm/createAuthClient.mjs +226 -0
  16. package/dist/esm/createAuthClient.mjs.map +1 -0
  17. package/dist/esm/createAuthClient.native.js +251 -0
  18. package/dist/esm/createAuthClient.native.js.map +1 -0
  19. package/dist/esm/createAuthClient.test.mjs +89 -0
  20. package/dist/esm/createAuthClient.test.mjs.map +1 -0
  21. package/dist/esm/createAuthClient.test.native.js +103 -0
  22. package/dist/esm/createAuthClient.test.native.js.map +1 -0
  23. package/dist/esm/index.js +2 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/index.mjs +2 -0
  26. package/dist/esm/index.mjs.map +1 -0
  27. package/dist/esm/index.native.js +2 -0
  28. package/dist/esm/index.native.js.map +1 -0
  29. package/dist/esm/server.mjs +77 -0
  30. package/dist/esm/server.mjs.map +1 -0
  31. package/dist/esm/server.native.js +196 -0
  32. package/dist/esm/server.native.js.map +1 -0
  33. package/package.json +66 -0
  34. package/src/createAuthClient.test.ts +86 -0
  35. package/src/createAuthClient.ts +373 -0
  36. package/src/index.ts +1 -0
  37. package/src/server.ts +150 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Better-auth helpers for React / React Native applications
3
+ *
4
+ * Features:
5
+ * - Session persistence in local storage
6
+ * - State management with emitters
7
+ * - Automatic retry on errors
8
+ * - Optional JWT support (for Tauri, React Native)
9
+ */
10
+
11
+ import {
12
+ createEmitter,
13
+ createStorageValue,
14
+ type Emitter,
15
+ isEqualDeepLite,
16
+ useEmitterValue,
17
+ } from '@o/helpers'
18
+ import { type BetterAuthClientOptions, createAuthClient } from 'better-auth/client'
19
+
20
+ import type { Session, User } from 'better-auth'
21
+
22
+ export interface StorageKeys {
23
+ token: string
24
+ session: string
25
+ }
26
+
27
+ export type AuthState<U extends User = User> = {
28
+ state: 'loading' | 'logged-in' | 'logged-out'
29
+ session: Session | null
30
+ user: U | null
31
+ /** JWT token - only populated when useJWT is enabled */
32
+ token: string | null
33
+ }
34
+
35
+ export interface BetterAuthClientProps<
36
+ TUser extends User = User,
37
+ > extends BetterAuthClientOptions {
38
+ /**
39
+ * Callback to transform and type the user object
40
+ * @default (user) => user
41
+ */
42
+ createUser?: (user: User) => TUser
43
+
44
+ /**
45
+ * Optional callback when authentication state changes
46
+ */
47
+ onAuthStateChange?: (state: AuthState<TUser>) => void
48
+
49
+ /**
50
+ * Optional callback for handling auth errors
51
+ */
52
+ onAuthError?: (error: any) => void
53
+
54
+ /**
55
+ * Storage key prefix for local storage
56
+ * @default 'auth'
57
+ */
58
+ storagePrefix?: string
59
+
60
+ /**
61
+ * Retry delay in milliseconds after auth errors
62
+ * @default 4000
63
+ */
64
+ retryDelay?: number
65
+
66
+ /**
67
+ * Enable JWT token management for native apps (Tauri, React Native)
68
+ * When false (default), auth uses session cookies forwarded by the server
69
+ * When true, fetches and manages JWT tokens for Authorization header auth
70
+ * @default false
71
+ */
72
+ useJWT?: boolean
73
+
74
+ /**
75
+ * Cookie names to clear on auth invalidation
76
+ * @default ['better-auth.jwt', 'better-auth.session_token']
77
+ */
78
+ authCookieNames?: string[]
79
+ }
80
+
81
+ export interface BetterAuthClientReturn<U extends User = User, TClient = any> {
82
+ clearState: () => void
83
+ authState: ReturnType<typeof createEmitter<AuthState<U>>>
84
+ authClient: TClient
85
+ setAuthClientToken: (props: { token: string; session: string }) => Promise<void>
86
+ clearAuthClientToken: () => void
87
+ clearAllAuth: () => void
88
+ useAuth: () => AuthState<U>
89
+ getAuth: () => AuthState<U> & { loggedIn: boolean }
90
+ getValidToken: () => Promise<string | undefined>
91
+ updateAuthClient: (session: string) => void
92
+ authClientVersion: Emitter<number>
93
+ }
94
+
95
+ type InferUser<T> = T extends { createUser?: (user: User) => infer R }
96
+ ? R extends User
97
+ ? R
98
+ : User
99
+ : User
100
+
101
+ export function createBetterAuthClient<const Opts extends BetterAuthClientProps<any>>(
102
+ options: Opts
103
+ ): BetterAuthClientReturn<InferUser<Opts>, ReturnType<typeof createAuthClient<Opts>>> {
104
+ type TUser = InferUser<Opts>
105
+ const {
106
+ onAuthStateChange,
107
+ onAuthError,
108
+ createUser,
109
+ storagePrefix = 'auth',
110
+ retryDelay = 4000,
111
+ useJWT = false,
112
+ authCookieNames = ['better-auth.jwt', 'better-auth.session_token'],
113
+ ...authClientOptions
114
+ } = options
115
+
116
+ const empty: AuthState<TUser> = {
117
+ state: 'logged-out',
118
+ session: null,
119
+ user: null,
120
+ token: null,
121
+ }
122
+
123
+ const keysStorage = createStorageValue<StorageKeys>(`${storagePrefix}-keys`)
124
+ const stateStorage = createStorageValue<AuthState<TUser>>(`${storagePrefix}-state`)
125
+
126
+ const createAuthClientWithSession = (session: string) => {
127
+ return createAuthClient({
128
+ ...authClientOptions,
129
+ fetchOptions: {
130
+ credentials: 'include',
131
+ headers: session ? { Authorization: `Bearer ${session}` } : undefined,
132
+ },
133
+ })
134
+ }
135
+
136
+ let authClient = (() => {
137
+ const existingSession = keysStorage.get()?.session
138
+ return existingSession
139
+ ? createAuthClientWithSession(existingSession)
140
+ : createAuthClient({
141
+ ...authClientOptions,
142
+ fetchOptions: { credentials: 'include' },
143
+ } as Opts)
144
+ })()
145
+
146
+ const authState = createEmitter<AuthState<TUser>>(
147
+ 'authState',
148
+ stateStorage.get() || empty,
149
+ { comparator: isEqualDeepLite }
150
+ )
151
+
152
+ const authClientVersion = createEmitter<number>('authClientVersion', 0)
153
+
154
+ const setState = (update: Partial<AuthState<TUser>>) => {
155
+ const current = authState.value!
156
+ const next = { ...current, ...update }
157
+ stateStorage.set(next)
158
+ authState.emit(next)
159
+
160
+ // update storage keys
161
+ if (next.token && next.session) {
162
+ keysStorage.set({
163
+ token: next.token,
164
+ session: next.session.token,
165
+ })
166
+ } else if (next.session) {
167
+ keysStorage.set({
168
+ token: '',
169
+ session: next.session.token,
170
+ })
171
+ } else {
172
+ keysStorage.set({ token: '', session: '' })
173
+ }
174
+
175
+ onAuthStateChange?.(next)
176
+ }
177
+
178
+ const setAuthClientToken = async (props: { token: string; session: string }) => {
179
+ keysStorage.set(props)
180
+ updateAuthClient(props.session)
181
+ }
182
+
183
+ function updateAuthClient(session: string) {
184
+ authClient = createAuthClientWithSession(session)
185
+ authClientVersion.emit(Math.random())
186
+ subscribeToAuthEffect()
187
+ }
188
+
189
+ let dispose: Function | null = null
190
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
191
+ // consecutive poll failures drive exponential backoff. a fixed retry clock
192
+ // is self-defeating against a rate limiter: one 429 puts the client on a
193
+ // cadence that keeps it over the limit forever (a real device wedged this
194
+ // way — every 4s retry re-tripped the server's window and the session never
195
+ // confirmed). backoff doubles per consecutive failure up to 60s and resets
196
+ // on any successful poll.
197
+ let consecutiveFailures = 0
198
+
199
+ function subscribeToAuthEffect() {
200
+ dispose?.()
201
+
202
+ dispose = authClient.useSession.subscribe(async (props) => {
203
+ const { data: dataGeneric, isPending, error } = props
204
+
205
+ if (error) {
206
+ onAuthError?.(error)
207
+ // if no persisted session, transition to logged-out instead of staying loading
208
+ const hasPersistedSession = !!keysStorage.get()?.session
209
+ if (!hasPersistedSession) {
210
+ setState({ state: 'logged-out', session: null, user: null })
211
+ }
212
+ consecutiveFailures++
213
+ scheduleAuthRetry(Math.min(retryDelay * 2 ** (consecutiveFailures - 1), 60_000))
214
+ return
215
+ }
216
+
217
+ consecutiveFailures = 0
218
+
219
+ const data = dataGeneric as
220
+ | undefined
221
+ | {
222
+ session?: AuthState<TUser>['session']
223
+ user?: AuthState<TUser>['user']
224
+ }
225
+
226
+ // if we have a persisted session but server hasn't confirmed yet, stay loading
227
+ const hasPersistedSession = !!keysStorage.get()?.session
228
+ const nextState = isPending
229
+ ? 'loading'
230
+ : data?.session
231
+ ? 'logged-in'
232
+ : hasPersistedSession && data === undefined
233
+ ? 'loading'
234
+ : 'logged-out'
235
+
236
+ // only update session/user when we have definitive data
237
+ const sessionUpdate =
238
+ nextState === 'loading'
239
+ ? {}
240
+ : {
241
+ session: data?.session ?? null,
242
+ user: data?.user ? (createUser ? createUser(data.user) : data.user) : null,
243
+ }
244
+
245
+ // detect new session
246
+ const previousSession = authState.value?.session
247
+ const isNewSession =
248
+ data?.session &&
249
+ (!previousSession ||
250
+ previousSession.id !== data.session.id ||
251
+ previousSession.userId !== data.session.userId)
252
+
253
+ setState({
254
+ state: nextState,
255
+ ...sessionUpdate,
256
+ })
257
+
258
+ // fetch JWT token when useJWT is enabled (for native/tauri apps)
259
+ if (useJWT && data?.session && (isNewSession || !authState.value.token)) {
260
+ if (isNewSession && authState.value.token) {
261
+ setState({ token: null })
262
+ }
263
+
264
+ getValidToken().then((token) => {
265
+ if (token) {
266
+ setState({ token })
267
+ }
268
+ })
269
+ }
270
+ })
271
+ }
272
+
273
+ function scheduleAuthRetry(delayMs: number) {
274
+ if (retryTimer) clearTimeout(retryTimer)
275
+ retryTimer = setTimeout(() => {
276
+ retryTimer = null
277
+ subscribeToAuthEffect()
278
+ }, delayMs)
279
+ }
280
+
281
+ async function getValidToken(): Promise<string | undefined> {
282
+ const res = await authClient.$fetch('/token')
283
+ if (res.error) {
284
+ console.error(`Error fetching token: ${res.error.statusText}`)
285
+ return undefined
286
+ }
287
+ return (res.data as any)?.token as string | undefined
288
+ }
289
+
290
+ const clearAuthClientToken = () => {
291
+ keysStorage.remove()
292
+ }
293
+
294
+ function clearAuthCookies() {
295
+ if (typeof document === 'undefined') return
296
+
297
+ for (const cookieName of authCookieNames) {
298
+ document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
299
+ const domain = window.location.hostname
300
+ document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${domain}`
301
+ if (domain.startsWith('.')) {
302
+ document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${domain.slice(1)}`
303
+ }
304
+ }
305
+ }
306
+
307
+ function clearAllAuth() {
308
+ clearAuthCookies()
309
+ clearState()
310
+ }
311
+
312
+ const getAuth = () => {
313
+ const state = authState?.value || empty
314
+ return { ...state, loggedIn: !!state.session }
315
+ }
316
+
317
+ const useAuth = () => {
318
+ return useEmitterValue(authState) || empty
319
+ }
320
+
321
+ function clearState() {
322
+ keysStorage.remove()
323
+ stateStorage.remove()
324
+ setState(empty)
325
+ }
326
+
327
+ subscribeToAuthEffect()
328
+
329
+ if (typeof window !== 'undefined' && window.addEventListener) {
330
+ const cleanup = () => {
331
+ dispose?.()
332
+ if (retryTimer) clearTimeout(retryTimer)
333
+ }
334
+ window.addEventListener('beforeunload', cleanup)
335
+ }
336
+
337
+ const proxiedAuthClient = new Proxy(authClient, {
338
+ get(_target, key) {
339
+ if (key === 'signOut') {
340
+ return async () => {
341
+ // an explicit empty body makes better-fetch send JSON instead of a
342
+ // bodyless POST, which Better Auth rejects with 415.
343
+ const result = await authClient.$fetch('/sign-out', {
344
+ method: 'POST',
345
+ body: {},
346
+ })
347
+ if (result.error) throw result.error
348
+
349
+ clearState()
350
+ if (typeof window !== 'undefined') {
351
+ window.location?.reload?.()
352
+ }
353
+ return result
354
+ }
355
+ }
356
+ return Reflect.get(authClient, key)
357
+ },
358
+ }) as ReturnType<typeof createAuthClient<Opts>>
359
+
360
+ return {
361
+ authClientVersion,
362
+ clearState,
363
+ authState,
364
+ authClient: proxiedAuthClient,
365
+ setAuthClientToken,
366
+ clearAuthClientToken,
367
+ clearAllAuth,
368
+ useAuth,
369
+ getAuth,
370
+ getValidToken,
371
+ updateAuthClient,
372
+ }
373
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './createAuthClient'
package/src/server.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Server-side auth utilities for better-auth
3
+ * - Session validation via cookies (web)
4
+ * - JWT validation via JWKS (native apps)
5
+ */
6
+
7
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
8
+
9
+ export interface ValidateTokenOptions {
10
+ /** base URL for the auth server (e.g., https://myapp.com) */
11
+ baseUrl?: string
12
+ /** optional issuer override for CI/test environments */
13
+ forceIssuer?: string
14
+ /** JWKS endpoint path, defaults to /api/auth/jwks */
15
+ jwksPath?: string
16
+ }
17
+
18
+ export interface AuthData {
19
+ id: string
20
+ email?: string
21
+ role: 'admin' | undefined
22
+ }
23
+
24
+ export class NotAuthenticatedError extends Error {}
25
+ export class InvalidTokenError extends Error {}
26
+
27
+ export type AuthServer = {
28
+ api: {
29
+ getSession: (opts: { headers: Headers }) =>
30
+ | Promise<{
31
+ user: { id: string; email?: string | null; role?: string | null }
32
+ } | null>
33
+ | Promise<any>
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Get auth data from request - tries session first, then a JWKS-verified JWT.
39
+ * Session: web cookies (forwarded by zero) and native bearer tokens resolved by
40
+ * the better-auth bearer() plugin — both handled by getSession.
41
+ * JWT: only for servers using the jwt() plugin instead of bearer().
42
+ */
43
+ export async function getAuthDataFromRequest(
44
+ authServer: AuthServer,
45
+ req: Request,
46
+ tokenOptions?: ValidateTokenOptions
47
+ ): Promise<AuthData | null> {
48
+ const authHeader = req.headers.get('authorization')
49
+
50
+ // try session-based auth first, passing the request headers THROUGH UNTOUCHED:
51
+ // - web sends the forwarded session cookie.
52
+ // - native (React Native / Tauri) sends `Authorization: Bearer <session-token>`,
53
+ // which the better-auth bearer() plugin verifies and rewrites into the session
54
+ // cookie internally (with the correct cookie name).
55
+ // do NOT rewrite the Cookie header from the bearer value: the bearer token is
56
+ // not a `name=value` cookie, so it never matched a real session cookie, and
57
+ // overwriting an existing forwarded cookie with it silently dropped authenticated
58
+ // web requests (cookie + bearer both present) to an `anon` identity.
59
+ try {
60
+ const session = await authServer.api.getSession({ headers: req.headers })
61
+ if (session?.user) {
62
+ return {
63
+ id: session.user.id,
64
+ email: session.user.email || undefined,
65
+ role: session.user.role === 'admin' ? 'admin' : undefined,
66
+ }
67
+ }
68
+ } catch (err) {
69
+ console.warn(`Error validating session`, err)
70
+ // session auth failed, try JWT
71
+ }
72
+
73
+ // fall back to JWKS-verified JWT for servers using the jwt() plugin (no
74
+ // bearer() plugin to resolve the token above).
75
+ const jwtToken = authHeader?.replace('Bearer ', '')
76
+
77
+ if (jwtToken) {
78
+ try {
79
+ const payload = await validateToken(jwtToken, tokenOptions)
80
+ const userId = (payload as any)?.id || payload?.sub
81
+ if (userId) {
82
+ return {
83
+ id: userId as string,
84
+ email: (payload as any).email as string | undefined,
85
+ role: (payload as any).role === 'admin' ? 'admin' : undefined,
86
+ }
87
+ }
88
+ } catch (err) {
89
+ if (!(err instanceof InvalidTokenError)) {
90
+ throw err
91
+ }
92
+ }
93
+ }
94
+
95
+ return null
96
+ }
97
+
98
+ // jwt validation for native apps
99
+
100
+ export async function validateToken(
101
+ token: string,
102
+ options?: ValidateTokenOptions
103
+ ): Promise<JWTPayload> {
104
+ const {
105
+ baseUrl = process.env.ONE_SERVER_URL,
106
+ forceIssuer = process.env.FORCE_ISSUER || '',
107
+ jwksPath = '/api/auth/jwks',
108
+ } = options || {}
109
+
110
+ if (!baseUrl) {
111
+ throw new Error(`No baseURL!`)
112
+ }
113
+
114
+ const normalizedBaseUrl = removeTrailingSlash(baseUrl)
115
+ const url = `${forceIssuer || normalizedBaseUrl}${jwksPath}`
116
+
117
+ // create fresh JWKS fetcher each time to avoid stale key cache issues
118
+ const JWKS = createRemoteJWKSet(new URL(url))
119
+
120
+ try {
121
+ const verifyOptions = forceIssuer
122
+ ? {}
123
+ : {
124
+ issuer: normalizedBaseUrl,
125
+ audience: normalizedBaseUrl,
126
+ }
127
+
128
+ const { payload } = await jwtVerify(token, JWKS, verifyOptions)
129
+
130
+ return payload
131
+ } catch (error) {
132
+ throw new InvalidTokenError(`${error}`)
133
+ }
134
+ }
135
+
136
+ export async function isValidJWT(
137
+ token: string,
138
+ options: ValidateTokenOptions
139
+ ): Promise<boolean> {
140
+ try {
141
+ await validateToken(token, options)
142
+ return true
143
+ } catch {
144
+ return false
145
+ }
146
+ }
147
+
148
+ function removeTrailingSlash(str: string) {
149
+ return str.replace(/\/$/, '')
150
+ }