@douvery/auth 0.3.2 → 0.4.0

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.
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * @douvery/auth/qwik - Qwik adapter
3
+ *
4
+ * Uses QRL for config to avoid Qwik serialization issues with
5
+ * function-based storage adapters (customStorage).
6
+ * The client is created inside useVisibleTask$ and wrapped with
7
+ * noSerialize() since DouveryAuthClient has non-serializable methods.
3
8
  */
4
9
 
5
10
  import {
@@ -10,8 +15,12 @@ import {
10
15
  useTask$,
11
16
  useVisibleTask$,
12
17
  component$,
18
+ $,
13
19
  Slot,
20
+ noSerialize,
14
21
  type Signal,
22
+ type NoSerialize,
23
+ type QRL,
15
24
  } from "@builder.io/qwik";
16
25
  import {
17
26
  DouveryAuthClient,
@@ -33,47 +42,111 @@ import {
33
42
  type AuthUrl,
34
43
  } from "@douvery/auth";
35
44
 
45
+ // ============================================================================
46
+ // Context
47
+ // ============================================================================
48
+
36
49
  interface DouveryAuthContextValue {
37
50
  state: Signal<AuthState>;
38
51
  isInitialized: Signal<boolean>;
39
52
  isLoading: Signal<boolean>;
40
53
  error: Signal<Error | null>;
41
- client: DouveryAuthClient;
54
+ clientRef: Signal<NoSerialize<DouveryAuthClient> | undefined>;
55
+ /** Application-specific user data from SSR (e.g. routeLoader$). */
56
+ appUser: Signal<unknown>;
57
+ appUserAuthenticated: Signal<boolean>;
42
58
  }
43
59
 
44
60
  export const DouveryAuthContext =
45
61
  createContextId<DouveryAuthContextValue>("douvery-auth");
46
62
 
63
+ // ============================================================================
64
+ // Provider
65
+ // ============================================================================
66
+
47
67
  export interface DouveryAuthProviderProps {
48
- config: DouveryAuthConfig;
68
+ /**
69
+ * QRL that returns the auth configuration.
70
+ * Use $(() => getDouveryAuthConfig()) to wrap your config factory.
71
+ * This avoids Qwik serialization issues with customStorage functions.
72
+ */
73
+ config$: QRL<() => DouveryAuthConfig>;
74
+ /**
75
+ * Optional application-specific user data loaded from SSR (routeLoader$).
76
+ * This is separate from OAuth user – it holds the full app user object
77
+ * (e.g. UserACC with address, active, sessionId, etc.).
78
+ * Pass the routeLoader$ signal directly.
79
+ */
80
+ appUser?: Signal<unknown>;
49
81
  }
50
82
 
83
+ const DEFAULT_STATE: AuthState = {
84
+ status: "unauthenticated",
85
+ user: null,
86
+ tokens: null,
87
+ error: null,
88
+ };
89
+
51
90
  export const DouveryAuthProvider = component$<DouveryAuthProviderProps>(
52
- ({ config }) => {
53
- const client = createDouveryAuth(config);
54
- const state = useSignal<AuthState>(client.getState());
91
+ ({ config$, appUser: externalAppUser }) => {
92
+ // All signals are serializable - no functions stored directly
93
+ const state = useSignal<AuthState>(DEFAULT_STATE);
55
94
  const isInitialized = useSignal(false);
56
95
  const isLoading = useSignal(false);
57
96
  const error = useSignal<Error | null>(null);
97
+ const clientRef = useSignal<NoSerialize<DouveryAuthClient>>();
98
+
99
+ // App user data: use external signal if provided, otherwise create internal one
100
+ const internalAppUser = useSignal<unknown>(externalAppUser?.value ?? null);
101
+ const appUser = externalAppUser ?? internalAppUser;
102
+ const appUserAuthenticated = useSignal<boolean>(!!appUser.value);
103
+
104
+ // Keep appUserAuthenticated in sync
105
+ useTask$(({ track }) => {
106
+ const u = track(() => appUser.value);
107
+ appUserAuthenticated.value = !!u;
108
+ });
58
109
 
59
110
  useContextProvider(DouveryAuthContext, {
60
111
  state,
61
112
  isInitialized,
62
113
  isLoading,
63
114
  error,
64
- client,
115
+ clientRef,
116
+ appUser,
117
+ appUserAuthenticated,
65
118
  });
66
119
 
67
- useVisibleTask$(() => {
68
- client
69
- .initialize()
70
- .then(() => {
71
- isInitialized.value = true;
72
- state.value = client.getState();
73
- })
74
- .catch((err) => {
75
- error.value = err instanceof Error ? err : new Error(String(err));
76
- });
120
+ // Client creation deferred to browser-only task.
121
+ // The QRL is invoked here, returning the full config (with customStorage).
122
+ // noSerialize() wraps the client so Qwik doesn't try to serialize it.
123
+ useVisibleTask$(async () => {
124
+ let config: DouveryAuthConfig | undefined;
125
+ try {
126
+ config = await config$();
127
+ } catch (err) {
128
+ error.value = err instanceof Error ? err : new Error(String(err));
129
+ return;
130
+ }
131
+
132
+ if (!config) {
133
+ error.value = new Error(
134
+ "[DouveryAuthProvider] config$() returned undefined. " +
135
+ "Check that the QRL correctly returns a DouveryAuthConfig object.",
136
+ );
137
+ return;
138
+ }
139
+
140
+ const client = createDouveryAuth(config);
141
+ clientRef.value = noSerialize(client);
142
+
143
+ try {
144
+ await client.initialize();
145
+ isInitialized.value = true;
146
+ state.value = client.getState();
147
+ } catch (err) {
148
+ error.value = err instanceof Error ? err : new Error(String(err));
149
+ }
77
150
 
78
151
  const unsubscribe = client.subscribe((event) => {
79
152
  state.value = client.getState();
@@ -93,10 +166,29 @@ export const DouveryAuthProvider = component$<DouveryAuthProviderProps>(
93
166
  },
94
167
  );
95
168
 
169
+ // ============================================================================
170
+ // Hooks
171
+ // ============================================================================
172
+
96
173
  export function useDouveryAuth() {
97
174
  return useContext(DouveryAuthContext);
98
175
  }
99
176
 
177
+ /**
178
+ * Internal helper: safely access the client from context.
179
+ * Throws if the client hasn't been initialized yet (before useVisibleTask$ runs).
180
+ */
181
+ function getClient(ctx: DouveryAuthContextValue): DouveryAuthClient {
182
+ const client = ctx.clientRef.value;
183
+ if (!client) {
184
+ throw new Error(
185
+ "DouveryAuth client not initialized. " +
186
+ "Ensure DouveryAuthProvider is mounted and the page has hydrated.",
187
+ );
188
+ }
189
+ return client;
190
+ }
191
+
100
192
  export function useUser(): Signal<User | null> {
101
193
  const { state } = useDouveryAuth();
102
194
  const user = useSignal<User | null>(state.value.user);
@@ -118,9 +210,11 @@ export function useIsAuthenticated(): Signal<boolean> {
118
210
  }
119
211
 
120
212
  export function useAuthActions() {
121
- const { client, isLoading, error } = useDouveryAuth();
213
+ const ctx = useDouveryAuth();
214
+ const { isLoading, error } = ctx;
122
215
 
123
- const login = async (options?: LoginOptions) => {
216
+ const login = $(async (options?: LoginOptions) => {
217
+ const client = getClient(ctx);
124
218
  isLoading.value = true;
125
219
  error.value = null;
126
220
  try {
@@ -131,9 +225,10 @@ export function useAuthActions() {
131
225
  } finally {
132
226
  isLoading.value = false;
133
227
  }
134
- };
228
+ });
135
229
 
136
- const logout = async (options?: LogoutOptions) => {
230
+ const logout = $(async (options?: LogoutOptions) => {
231
+ const client = getClient(ctx);
137
232
  isLoading.value = true;
138
233
  error.value = null;
139
234
  try {
@@ -144,41 +239,42 @@ export function useAuthActions() {
144
239
  } finally {
145
240
  isLoading.value = false;
146
241
  }
147
- };
242
+ });
148
243
 
149
- const selectAccount = (options?: SelectAccountOptions) => {
150
- client.selectAccount(options);
151
- };
244
+ const selectAccount = $((options?: SelectAccountOptions) => {
245
+ getClient(ctx).selectAccount(options);
246
+ });
152
247
 
153
- const addAccount = (options?: AddAccountOptions) => {
154
- client.addAccount(options);
155
- };
248
+ const addAccount = $((options?: AddAccountOptions) => {
249
+ getClient(ctx).addAccount(options);
250
+ });
156
251
 
157
- const register = (options?: RegisterOptions) => {
158
- client.register(options);
159
- };
252
+ const register = $((options?: RegisterOptions) => {
253
+ getClient(ctx).register(options);
254
+ });
160
255
 
161
- const recoverAccount = (options?: RecoverAccountOptions) => {
162
- client.recoverAccount(options);
163
- };
256
+ const recoverAccount = $((options?: RecoverAccountOptions) => {
257
+ getClient(ctx).recoverAccount(options);
258
+ });
164
259
 
165
- const verifyAccount = (options?: VerifyAccountOptions) => {
166
- client.verifyAccount(options);
167
- };
260
+ const verifyAccount = $((options?: VerifyAccountOptions) => {
261
+ getClient(ctx).verifyAccount(options);
262
+ });
168
263
 
169
- const upgradeAccount = (options?: UpgradeAccountOptions) => {
170
- client.upgradeAccount(options);
171
- };
264
+ const upgradeAccount = $((options?: UpgradeAccountOptions) => {
265
+ getClient(ctx).upgradeAccount(options);
266
+ });
172
267
 
173
- const setupPasskey = (options?: SetupPasskeyOptions) => {
174
- client.setupPasskey(options);
175
- };
268
+ const setupPasskey = $((options?: SetupPasskeyOptions) => {
269
+ getClient(ctx).setupPasskey(options);
270
+ });
176
271
 
177
- const setupAddress = (options?: SetupAddressOptions) => {
178
- client.setupAddress(options);
179
- };
272
+ const setupAddress = $((options?: SetupAddressOptions) => {
273
+ getClient(ctx).setupAddress(options);
274
+ });
180
275
 
181
- const revokeToken = async (options?: RevokeTokenOptions) => {
276
+ const revokeToken = $(async (options?: RevokeTokenOptions) => {
277
+ const client = getClient(ctx);
182
278
  isLoading.value = true;
183
279
  error.value = null;
184
280
  try {
@@ -189,7 +285,7 @@ export function useAuthActions() {
189
285
  } finally {
190
286
  isLoading.value = false;
191
287
  }
192
- };
288
+ });
193
289
 
194
290
  return {
195
291
  login,
@@ -209,48 +305,135 @@ export function useAuthActions() {
209
305
 
210
306
  /** Get URL builders for auth pages (non-redirecting, useful for <a> tags) */
211
307
  export function useAuthUrls() {
212
- const { client } = useDouveryAuth();
308
+ const ctx = useDouveryAuth();
213
309
  return {
214
- loginUrl: (options?: LoginOptions): AuthUrl =>
215
- client.buildLoginUrl(options),
216
- logoutUrl: (options?: LogoutOptions): AuthUrl =>
217
- client.buildLogoutUrl(options),
218
- selectAccountUrl: (options?: SelectAccountOptions): AuthUrl =>
219
- client.buildSelectAccountUrl(options),
220
- addAccountUrl: (options?: AddAccountOptions): AuthUrl =>
221
- client.buildAddAccountUrl(options),
222
- registerUrl: (options?: RegisterOptions): AuthUrl =>
223
- client.buildRegisterUrl(options),
224
- recoverAccountUrl: (options?: RecoverAccountOptions): AuthUrl =>
225
- client.buildRecoverAccountUrl(options),
226
- verifyAccountUrl: (options?: VerifyAccountOptions): AuthUrl =>
227
- client.buildVerifyAccountUrl(options),
228
- upgradeAccountUrl: (options?: UpgradeAccountOptions): AuthUrl =>
229
- client.buildUpgradeAccountUrl(options),
230
- setupPasskeyUrl: (options?: SetupPasskeyOptions): AuthUrl =>
231
- client.buildSetupPasskeyUrl(options),
232
- setupAddressUrl: (options?: SetupAddressOptions): AuthUrl =>
233
- client.buildSetupAddressUrl(options),
310
+ loginUrl: $(
311
+ (options?: LoginOptions): AuthUrl =>
312
+ getClient(ctx).buildLoginUrl(options),
313
+ ),
314
+ logoutUrl: $(
315
+ (options?: LogoutOptions): AuthUrl =>
316
+ getClient(ctx).buildLogoutUrl(options),
317
+ ),
318
+ selectAccountUrl: $(
319
+ (options?: SelectAccountOptions): AuthUrl =>
320
+ getClient(ctx).buildSelectAccountUrl(options),
321
+ ),
322
+ addAccountUrl: $(
323
+ (options?: AddAccountOptions): AuthUrl =>
324
+ getClient(ctx).buildAddAccountUrl(options),
325
+ ),
326
+ registerUrl: $(
327
+ (options?: RegisterOptions): AuthUrl =>
328
+ getClient(ctx).buildRegisterUrl(options),
329
+ ),
330
+ recoverAccountUrl: $(
331
+ (options?: RecoverAccountOptions): AuthUrl =>
332
+ getClient(ctx).buildRecoverAccountUrl(options),
333
+ ),
334
+ verifyAccountUrl: $(
335
+ (options?: VerifyAccountOptions): AuthUrl =>
336
+ getClient(ctx).buildVerifyAccountUrl(options),
337
+ ),
338
+ upgradeAccountUrl: $(
339
+ (options?: UpgradeAccountOptions): AuthUrl =>
340
+ getClient(ctx).buildUpgradeAccountUrl(options),
341
+ ),
342
+ setupPasskeyUrl: $(
343
+ (options?: SetupPasskeyOptions): AuthUrl =>
344
+ getClient(ctx).buildSetupPasskeyUrl(options),
345
+ ),
346
+ setupAddressUrl: $(
347
+ (options?: SetupAddressOptions): AuthUrl =>
348
+ getClient(ctx).buildSetupAddressUrl(options),
349
+ ),
234
350
  };
235
351
  }
236
352
 
237
353
  /** Get session status helpers */
238
354
  export function useSessionStatus() {
239
- const { client, state } = useDouveryAuth();
240
- const isExpired = useSignal(client.isSessionExpired());
241
- const needsVerification = useSignal(client.needsEmailVerification());
242
- const isGuest = useSignal(client.isGuestAccount());
355
+ const ctx = useDouveryAuth();
356
+ const { state } = ctx;
357
+ const c = ctx.clientRef.value;
358
+ const isExpired = useSignal(c ? c.isSessionExpired() : false);
359
+ const needsVerification = useSignal(c ? c.needsEmailVerification() : false);
360
+ const isGuest = useSignal(c ? c.isGuestAccount() : false);
243
361
 
244
362
  useTask$(({ track }) => {
245
363
  track(() => state.value);
246
- isExpired.value = client.isSessionExpired();
247
- needsVerification.value = client.needsEmailVerification();
248
- isGuest.value = client.isGuestAccount();
364
+ const client = ctx.clientRef.value;
365
+ if (client) {
366
+ isExpired.value = client.isSessionExpired();
367
+ needsVerification.value = client.needsEmailVerification();
368
+ isGuest.value = client.isGuestAccount();
369
+ }
249
370
  });
250
371
 
251
372
  return { isExpired, needsVerification, isGuest };
252
373
  }
253
374
 
375
+ // ============================================================================
376
+ // App User hooks
377
+ // ============================================================================
378
+
379
+ /**
380
+ * Returns the application-specific user data provided via `appUser` prop.
381
+ * Cast to your app's user type: `const user = useAppUser<UserACC>()`.
382
+ * Returns `{ user: Signal<T | null>, isAuthenticated: Signal<boolean> }`.
383
+ */
384
+ export function useAppUser<T = unknown>() {
385
+ const { appUser, appUserAuthenticated } = useDouveryAuth();
386
+ return {
387
+ user: appUser as Signal<T | null>,
388
+ isAuthenticated: appUserAuthenticated,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Full app user context with refresh capabilities.
394
+ * Use when you need to re-fetch user data from the server.
395
+ */
396
+ export function useAppUserActions<T = unknown>() {
397
+ const { appUser, appUserAuthenticated } = useDouveryAuth();
398
+
399
+ const updateUser = $((userData: T | null) => {
400
+ (appUser as Signal<T | null>).value = userData;
401
+ appUserAuthenticated.value = !!userData;
402
+ });
403
+
404
+ const refreshUser = $(async () => {
405
+ try {
406
+ const response = await fetch("/api/auth/me", {
407
+ method: "GET",
408
+ credentials: "include",
409
+ headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
410
+ });
411
+
412
+ if (response.ok) {
413
+ const data = await response.json();
414
+ (appUser as Signal<T | null>).value = data.user;
415
+ appUserAuthenticated.value = true;
416
+ } else if (response.status === 401 || response.status === 403) {
417
+ (appUser as Signal<T | null>).value = null;
418
+ appUserAuthenticated.value = false;
419
+ }
420
+ } catch {
421
+ // Network error: keep current state
422
+ }
423
+ });
424
+
425
+ return {
426
+ user: appUser as Signal<T | null>,
427
+ isAuthenticated: appUserAuthenticated,
428
+ updateUser,
429
+ refreshUser,
430
+ };
431
+ }
432
+
433
+ // ============================================================================
434
+ // Re-exports
435
+ // ============================================================================
436
+
254
437
  export { DouveryAuthClient, createDouveryAuth } from "@douvery/auth";
255
438
  export type {
256
439
  DouveryAuthConfig,
@@ -268,4 +451,9 @@ export type {
268
451
  AddAccountOptions,
269
452
  RevokeTokenOptions,
270
453
  AuthUrl,
454
+ CookieAdapter,
455
+ CookieSetOptions,
271
456
  } from "@douvery/auth";
457
+
458
+ // Session adapter for Qwik City
459
+ export { createQwikSessionAdapter } from "./session";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @douvery/auth/qwik - Session Adapter
3
+ *
4
+ * Adapts Qwik City's Cookie interface to the generic CookieAdapter
5
+ * used by createSessionResolver().
6
+ *
7
+ * Memoized: returns the same adapter instance for the same Cookie object,
8
+ * ensuring the resolver's per-request WeakMap cache works correctly when
9
+ * multiple routeLoaders call getAccessToken() in the same SSR request.
10
+ */
11
+
12
+ import type { CookieAdapter, CookieSetOptions } from "@douvery/auth";
13
+
14
+ /**
15
+ * Qwik City Cookie-like interface.
16
+ * Duck-typed to avoid hard dependency on @builder.io/qwik-city.
17
+ */
18
+ interface QwikCookieLike {
19
+ get(name: string): { value: string } | null;
20
+ set(
21
+ name: string,
22
+ value: string | number | Record<string, unknown>,
23
+ options?: Record<string, unknown>,
24
+ ): void;
25
+ }
26
+
27
+ /**
28
+ * Adapter cache ensures the SAME CookieAdapter instance is returned
29
+ * for the same Qwik Cookie object. This is critical because:
30
+ *
31
+ * 1. The resolver uses WeakMap<CookieAdapter> for per-request caching
32
+ * 2. Multiple routeLoaders in the same SSR request share the same Cookie
33
+ * 3. Each routeLoader calls createQwikSessionAdapter(cookie)
34
+ * 4. Without memoization, each call would create a different object
35
+ * → WeakMap would fail to deduplicate → duplicate network calls
36
+ */
37
+ const adapterCache = new WeakMap<object, CookieAdapter>();
38
+
39
+ /**
40
+ * Create a CookieAdapter from a Qwik City Cookie object.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * import { createQwikSessionAdapter } from '@douvery/auth/qwik';
45
+ * import { createSessionResolver } from '@douvery/auth/session';
46
+ *
47
+ * const resolver = createSessionResolver({ ... });
48
+ *
49
+ * export const useMyLoader = routeLoader$(async ({ cookie }) => {
50
+ * const adapter = createQwikSessionAdapter(cookie);
51
+ * const token = await resolver.getAccessToken(adapter);
52
+ * });
53
+ * ```
54
+ */
55
+ export function createQwikSessionAdapter(
56
+ cookie: QwikCookieLike,
57
+ ): CookieAdapter {
58
+ let adapter = adapterCache.get(cookie);
59
+ if (adapter) return adapter;
60
+
61
+ adapter = {
62
+ get(name: string): string | undefined {
63
+ return cookie.get(name)?.value ?? undefined;
64
+ },
65
+ set(name: string, value: string, options: CookieSetOptions): void {
66
+ cookie.set(name, value, options as Record<string, unknown>);
67
+ },
68
+ };
69
+
70
+ adapterCache.set(cookie, adapter);
71
+ return adapter;
72
+ }