@netlify/identity 0.3.1-alpha.6 → 0.4.2

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 CHANGED
@@ -8,6 +8,7 @@ This is NOT the Netlify Identity Widget. This library exports standalone async f
8
8
  **Prerequisites:**
9
9
 
10
10
  - [Netlify Identity](https://docs.netlify.com/security/secure-access-to-sites/identity/) must be enabled on your Netlify project
11
+ - **Server-side** functions (`getUser`, `login`, `admin.*`, etc.) require [Netlify Functions](https://docs.netlify.com/build/functions/get-started/) (modern/v2, with `export default`) or [Edge Functions](https://docs.netlify.com/edge-functions/overview/). [Lambda-compatible functions](https://docs.netlify.com/build/functions/lambda-compatibility/) (v1, with `export { handler }`) are **not supported**
11
12
  - For local development, use [`netlify dev`](https://docs.netlify.com/cli/local-development/) so the Identity endpoint is available
12
13
 
13
14
  ## How this library relates to other Netlify auth packages
@@ -37,6 +38,7 @@ This library provides a unified API that works in both browser and server contex
37
38
  - [Password recovery](#password-recovery)
38
39
  - [Invite acceptance](#invite-acceptance)
39
40
  - [Session lifetime](#session-lifetime)
41
+ - [Caching and authenticated content](#caching-and-authenticated-content)
40
42
 
41
43
  ## Installation
42
44
 
@@ -301,10 +303,9 @@ Updates the current user's metadata or credentials. Requires an active session.
301
303
 
302
304
  ### Admin Operations
303
305
 
304
- The `admin` namespace provides user management functions for administrators. These work in two contexts:
306
+ The `admin` namespace provides server-only user management functions. Admin methods use the operator token from the Netlify runtime, which is automatically available in Netlify Functions and Edge Functions.
305
307
 
306
- - **Server:** Uses the operator token from the Netlify runtime for full admin access. No logged-in user required.
307
- - **Browser:** Uses the logged-in user's JWT. The user must have an admin role.
308
+ Calling any admin method from a browser environment throws an `AuthError`.
308
309
 
309
310
  ```ts
310
311
  import { admin } from '@netlify/identity'
@@ -340,9 +341,9 @@ export default async (req: Request, context: Context) => {
340
341
  admin.listUsers(options?: ListUsersOptions): Promise<User[]>
341
342
  ```
342
343
 
343
- Lists all users. Pagination options are supported on the server; they are ignored in the browser.
344
+ Lists all users. Pagination options (`page`, `perPage`) are forwarded as query parameters.
344
345
 
345
- **Throws:** `AuthError` if the operator token is missing (server) or no user is logged in (browser).
346
+ **Throws:** `AuthError` if called from a browser, or if the operator token is missing.
346
347
 
347
348
  #### `admin.getUser`
348
349
 
@@ -352,7 +353,7 @@ admin.getUser(userId: string): Promise<User>
352
353
 
353
354
  Gets a single user by ID.
354
355
 
355
- **Throws:** `AuthError` if the user is not found, the operator token is missing (server), or no user is logged in (browser).
356
+ **Throws:** `AuthError` if called from a browser, the user is not found, or the operator token is missing.
356
357
 
357
358
  #### `admin.createUser`
358
359
 
@@ -360,9 +361,9 @@ Gets a single user by ID.
360
361
  admin.createUser(params: CreateUserParams): Promise<User>
361
362
  ```
362
363
 
363
- Creates a new user. The user is auto-confirmed. Optional `data` is spread into the request body as additional attributes.
364
+ Creates a new user. The user is auto-confirmed. Optional `data` forwards allowed fields (`role`, `aud`, `app_metadata`, `user_metadata`) to the request body. Other keys are silently ignored. `data` cannot override `email`, `password`, or `confirm`.
364
365
 
365
- **Throws:** `AuthError` on failure (e.g., email already exists).
366
+ **Throws:** `AuthError` if called from a browser, the email already exists, or the operator token is missing.
366
367
 
367
368
  #### `admin.updateUser`
368
369
 
@@ -370,9 +371,9 @@ Creates a new user. The user is auto-confirmed. Optional `data` is spread into t
370
371
  admin.updateUser(userId: string, attributes: AdminUserUpdates): Promise<User>
371
372
  ```
372
373
 
373
- Updates an existing user by ID. Pass any attributes to change (e.g., `{ email: 'new@example.com' }`). See `AdminUserUpdates` for typed fields.
374
+ Updates an existing user by ID. Only typed `AdminUserUpdates` fields are forwarded (e.g., `{ email: 'new@example.com' }`, `{ role: 'editor' }`).
374
375
 
375
- **Throws:** `AuthError` if the user is not found or the update fails.
376
+ **Throws:** `AuthError` if called from a browser, the user is not found, or the update fails.
376
377
 
377
378
  #### `admin.deleteUser`
378
379
 
@@ -382,7 +383,7 @@ admin.deleteUser(userId: string): Promise<void>
382
383
 
383
384
  Deletes a user by ID.
384
385
 
385
- **Throws:** `AuthError` if the user is not found or the deletion fails.
386
+ **Throws:** `AuthError` if called from a browser, the user is not found, or the deletion fails.
386
387
 
387
388
  ### Types
388
389
 
@@ -400,6 +401,7 @@ interface User {
400
401
  pictureUrl?: string
401
402
  roles?: string[]
402
403
  metadata?: Record<string, unknown>
404
+ appMetadata?: Record<string, unknown>
403
405
  rawGoTrueData?: Record<string, unknown>
404
406
  }
405
407
  ```
@@ -449,14 +451,14 @@ interface AdminUserUpdates {
449
451
  email?: string
450
452
  password?: string
451
453
  role?: string
454
+ aud?: string
452
455
  confirm?: boolean
453
456
  app_metadata?: Record<string, unknown>
454
457
  user_metadata?: Record<string, unknown>
455
- [key: string]: unknown
456
458
  }
457
459
  ```
458
460
 
459
- Fields accepted by `admin.updateUser()`. Unlike `UserUpdates`, admin updates can set `role`, force-confirm a user, and write to `app_metadata`.
461
+ Fields accepted by `admin.updateUser()`. Unlike `UserUpdates`, admin updates can set `role`, `aud`, force-confirm a user, and write to `app_metadata`. Only these typed fields are forwarded.
460
462
 
461
463
  #### `SignupData`
462
464
 
@@ -485,7 +487,7 @@ interface ListUsersOptions {
485
487
  }
486
488
  ```
487
489
 
488
- Pagination options for `admin.listUsers()`. Only used on the server; pagination is ignored in the browser.
490
+ Pagination options for `admin.listUsers()`.
489
491
 
490
492
  #### `CreateUserParams`
491
493
 
@@ -497,7 +499,7 @@ interface CreateUserParams {
497
499
  }
498
500
  ```
499
501
 
500
- Parameters for `admin.createUser()`. Optional `data` is spread into the request body as top-level attributes (use it to set `app_metadata`, `user_metadata`, `role`, etc.).
502
+ Parameters for `admin.createUser()`. Optional `data` forwards allowed fields (`role`, `aud`, `app_metadata`, `user_metadata`) to the request body. Other keys are silently ignored.
501
503
 
502
504
  #### `Admin`
503
505
 
@@ -1046,6 +1048,20 @@ Sessions are managed by Netlify Identity on the server side. The library stores
1046
1048
 
1047
1049
  Session lifetime is configured in your Netlify Identity settings, not in this library.
1048
1050
 
1051
+ ### Caching and authenticated content
1052
+
1053
+ Pages that display user-specific data (names, emails, roles, account settings) should not be served from a shared cache. If a cache stores an authenticated response and serves it to a different user, that user sees someone else's data. This applies to any authentication system, not just Netlify Identity.
1054
+
1055
+ **Next.js App Router** has multiple caching layers that are active by default:
1056
+
1057
+ - **Static rendering:** Server Components are statically rendered at build time unless they call a [Dynamic API](https://nextjs.org/docs/app/guides/caching#dynamic-rendering) like `cookies()`. This library's `getUser()` already calls `headers()` internally to opt the route into dynamic rendering, but if you check auth state without calling `getUser()` (e.g., reading the `nf_jwt` cookie directly), the page may still be statically cached. Always use `getUser()` rather than reading cookies directly.
1058
+ - **ISR (Incremental Static Regeneration):** Do not use ISR for pages that display user-specific content. ISR regenerates the page for the first visitor after the revalidation window and caches the result for all subsequent visitors.
1059
+ - **`use cache` / `unstable_cache`:** These directives cannot access `cookies()` or `headers()` directly. If you need to cache part of an authenticated page, read cookies outside the cache scope and pass relevant values as arguments.
1060
+
1061
+ > **Note:** Next.js caching defaults have changed across versions. For example, [Next.js 15 changed `fetch` requests, `GET` Route Handlers, and the client Router Cache to be uncached by default](https://nextjs.org/blog/next-15#caching-semantics), reversing the previous opt-out model. Check the [caching guide](https://nextjs.org/docs/app/guides/caching) for your specific Next.js version.
1062
+
1063
+ **Other SSR frameworks (Remix, Astro, SvelteKit, TanStack Start):** These frameworks do not cache SSR responses by default. If you add caching headers to improve performance, exclude routes that call `getUser()` or read auth cookies.
1064
+
1049
1065
  ## License
1050
1066
 
1051
1067
  MIT
package/dist/index.cjs CHANGED
@@ -227,6 +227,24 @@ var triggerNextjsDynamic = () => {
227
227
  }
228
228
  };
229
229
 
230
+ // src/fetch.ts
231
+ var DEFAULT_TIMEOUT_MS = 5e3;
232
+ var fetchWithTimeout = async (url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) => {
233
+ const controller = new AbortController();
234
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
235
+ try {
236
+ return await fetch(url, { ...options, signal: controller.signal });
237
+ } catch (error) {
238
+ if (error instanceof Error && error.name === "AbortError") {
239
+ const pathname = new URL(url).pathname;
240
+ throw new AuthError(`Identity request to ${pathname} timed out after ${timeoutMs}ms`);
241
+ }
242
+ throw error;
243
+ } finally {
244
+ clearTimeout(timer);
245
+ }
246
+ };
247
+
230
248
  // src/events.ts
231
249
  var AUTH_EVENTS = {
232
250
  LOGIN: "login",
@@ -344,7 +362,7 @@ var refreshSession = async () => {
344
362
  }
345
363
  let res;
346
364
  try {
347
- res = await fetch(`${identityUrl}/token`, {
365
+ res = await fetchWithTimeout(`${identityUrl}/token`, {
348
366
  method: "POST",
349
367
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
350
368
  body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }).toString()
@@ -398,7 +416,7 @@ var login = async (email, password) => {
398
416
  });
399
417
  let res;
400
418
  try {
401
- res = await fetch(`${identityUrl}/token`, {
419
+ res = await fetchWithTimeout(`${identityUrl}/token`, {
402
420
  method: "POST",
403
421
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
404
422
  body: body.toString()
@@ -414,7 +432,7 @@ var login = async (email, password) => {
414
432
  const accessToken = data.access_token;
415
433
  let userRes;
416
434
  try {
417
- userRes = await fetch(`${identityUrl}/user`, {
435
+ userRes = await fetchWithTimeout(`${identityUrl}/user`, {
418
436
  headers: { Authorization: `Bearer ${accessToken}` }
419
437
  });
420
438
  } catch (error) {
@@ -448,7 +466,7 @@ var signup = async (email, password, data) => {
448
466
  const cookies = getCookies();
449
467
  let res;
450
468
  try {
451
- res = await fetch(`${identityUrl}/signup`, {
469
+ res = await fetchWithTimeout(`${identityUrl}/signup`, {
452
470
  method: "POST",
453
471
  headers: { "Content-Type": "application/json" },
454
472
  body: JSON.stringify({ email, password, data })
@@ -495,7 +513,7 @@ var logout = async () => {
495
513
  const jwt = cookies.get(NF_JWT_COOKIE);
496
514
  if (jwt) {
497
515
  try {
498
- await fetch(`${identityUrl}/logout`, {
516
+ await fetchWithTimeout(`${identityUrl}/logout`, {
499
517
  method: "POST",
500
518
  headers: { Authorization: `Bearer ${jwt}` }
501
519
  });
@@ -675,6 +693,7 @@ var toUser = (userData) => {
675
693
  const appMeta = userData.app_metadata ?? {};
676
694
  const name = userMeta.full_name || userMeta.name;
677
695
  const pictureUrl = userMeta.avatar_url;
696
+ const { token: _token, ...safeUserData } = userData;
678
697
  return {
679
698
  id: userData.id,
680
699
  email: userData.email,
@@ -686,7 +705,8 @@ var toUser = (userData) => {
686
705
  pictureUrl: typeof pictureUrl === "string" ? pictureUrl : void 0,
687
706
  roles: toRoles(appMeta),
688
707
  metadata: userMeta,
689
- rawGoTrueData: { ...userData }
708
+ appMetadata: appMeta,
709
+ rawGoTrueData: { ...safeUserData }
690
710
  };
691
711
  };
692
712
  var claimsToUser = (claims) => {
@@ -701,7 +721,8 @@ var claimsToUser = (claims) => {
701
721
  name: typeof name === "string" ? name : void 0,
702
722
  pictureUrl: typeof pictureUrl === "string" ? pictureUrl : void 0,
703
723
  roles: toRoles(appMeta),
704
- metadata: userMeta
724
+ metadata: userMeta,
725
+ appMetadata: appMeta
705
726
  };
706
727
  };
707
728
  var decodeJwtPayload = (token) => {
@@ -716,7 +737,7 @@ var decodeJwtPayload = (token) => {
716
737
  };
717
738
  var fetchFullUser = async (identityUrl, jwt) => {
718
739
  try {
719
- const res = await fetch(`${identityUrl}/user`, {
740
+ const res = await fetchWithTimeout(`${identityUrl}/user`, {
720
741
  headers: { Authorization: `Bearer ${jwt}` }
721
742
  });
722
743
  if (!res.ok) return null;
@@ -905,6 +926,19 @@ var updateUser = async (updates) => {
905
926
  };
906
927
 
907
928
  // src/admin.ts
929
+ var SERVER_ONLY_MESSAGE = "Admin operations are server-only. Call admin methods from a Netlify Function or Edge Function, not from browser code.";
930
+ var sanitizeUserId = (userId) => {
931
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
932
+ if (!uuidRegex.test(userId)) {
933
+ throw new AuthError("User ID is not a valid UUID");
934
+ }
935
+ return encodeURIComponent(userId);
936
+ };
937
+ var assertServer = () => {
938
+ if (isBrowser()) {
939
+ throw new AuthError(SERVER_ONLY_MESSAGE);
940
+ }
941
+ };
908
942
  var getAdminAuth = () => {
909
943
  const ctx = getIdentityContext();
910
944
  if (!ctx?.url) {
@@ -919,7 +953,7 @@ var adminFetch = async (path, options = {}) => {
919
953
  const { url, token } = getAdminAuth();
920
954
  let res;
921
955
  try {
922
- res = await fetch(`${url}${path}`, {
956
+ res = await fetchWithTimeout(`${url}${path}`, {
923
957
  ...options,
924
958
  headers: {
925
959
  ...options.headers,
@@ -936,105 +970,67 @@ var adminFetch = async (path, options = {}) => {
936
970
  }
937
971
  return res;
938
972
  };
939
- var getAdminUser = () => {
940
- const client = getClient();
941
- const user = client.currentUser();
942
- if (!user) {
943
- throw new AuthError("Admin operations require a logged-in user with admin role");
944
- }
945
- return user;
946
- };
947
973
  var listUsers = async (options) => {
948
- if (!isBrowser()) {
949
- const params = new URLSearchParams();
950
- if (options?.page != null) params.set("page", String(options.page));
951
- if (options?.perPage != null) params.set("per_page", String(options.perPage));
952
- const query = params.toString();
953
- const path = `/admin/users${query ? `?${query}` : ""}`;
954
- const res = await adminFetch(path);
955
- const body = await res.json();
956
- return body.users.map(toUser);
957
- }
958
- try {
959
- const user = getAdminUser();
960
- const users = await user.admin.listUsers("");
961
- return users.map(toUser);
962
- } catch (error) {
963
- if (error instanceof AuthError) throw error;
964
- throw new AuthError(error.message, void 0, { cause: error });
965
- }
974
+ assertServer();
975
+ const params = new URLSearchParams();
976
+ if (options?.page != null) params.set("page", String(options.page));
977
+ if (options?.perPage != null) params.set("per_page", String(options.perPage));
978
+ const query = params.toString();
979
+ const path = `/admin/users${query ? `?${query}` : ""}`;
980
+ const res = await adminFetch(path);
981
+ const body = await res.json();
982
+ return body.users.map(toUser);
966
983
  };
967
984
  var getUser2 = async (userId) => {
968
- if (!isBrowser()) {
969
- const res = await adminFetch(`/admin/users/${userId}`);
970
- const userData = await res.json();
971
- return toUser(userData);
972
- }
973
- try {
974
- const user = getAdminUser();
975
- const userData = await user.admin.getUser({ id: userId });
976
- return toUser(userData);
977
- } catch (error) {
978
- if (error instanceof AuthError) throw error;
979
- throw new AuthError(error.message, void 0, { cause: error });
980
- }
985
+ assertServer();
986
+ const sanitizedUserId = sanitizeUserId(userId);
987
+ const res = await adminFetch(`/admin/users/${sanitizedUserId}`);
988
+ const userData = await res.json();
989
+ return toUser(userData);
981
990
  };
982
991
  var createUser = async (params) => {
983
- if (!isBrowser()) {
984
- const res = await adminFetch("/admin/users", {
985
- method: "POST",
986
- body: JSON.stringify({
987
- email: params.email,
988
- password: params.password,
989
- ...params.data,
990
- confirm: true
991
- })
992
- });
993
- const userData = await res.json();
994
- return toUser(userData);
995
- }
996
- try {
997
- const user = getAdminUser();
998
- const userData = await user.admin.createUser(params.email, params.password, {
999
- ...params.data,
1000
- confirm: true
1001
- });
1002
- return toUser(userData);
1003
- } catch (error) {
1004
- if (error instanceof AuthError) throw error;
1005
- throw new AuthError(error.message, void 0, { cause: error });
992
+ assertServer();
993
+ const body = {
994
+ email: params.email,
995
+ password: params.password,
996
+ confirm: true
997
+ };
998
+ if (params.data) {
999
+ const allowedKeys = ["role", "aud", "app_metadata", "user_metadata"];
1000
+ for (const key of allowedKeys) {
1001
+ if (key in params.data) {
1002
+ body[key] = params.data[key];
1003
+ }
1004
+ }
1006
1005
  }
1006
+ const res = await adminFetch("/admin/users", {
1007
+ method: "POST",
1008
+ body: JSON.stringify(body)
1009
+ });
1010
+ const userData = await res.json();
1011
+ return toUser(userData);
1007
1012
  };
1008
1013
  var updateUser2 = async (userId, attributes) => {
1009
- if (!isBrowser()) {
1010
- const res = await adminFetch(`/admin/users/${userId}`, {
1011
- method: "PUT",
1012
- body: JSON.stringify(attributes)
1013
- });
1014
- const userData = await res.json();
1015
- return toUser(userData);
1016
- }
1017
- try {
1018
- const user = getAdminUser();
1019
- const userData = await user.admin.updateUser({ id: userId }, attributes);
1020
- return toUser(userData);
1021
- } catch (error) {
1022
- if (error instanceof AuthError) throw error;
1023
- throw new AuthError(error.message, void 0, { cause: error });
1014
+ assertServer();
1015
+ const sanitizedUserId = sanitizeUserId(userId);
1016
+ const body = {};
1017
+ const allowedKeys = ["email", "password", "role", "aud", "confirm", "app_metadata", "user_metadata"];
1018
+ for (const key of allowedKeys) {
1019
+ if (key in attributes) {
1020
+ body[key] = attributes[key];
1021
+ }
1024
1022
  }
1023
+ const res = await adminFetch(`/admin/users/${sanitizedUserId}`, {
1024
+ method: "PUT",
1025
+ body: JSON.stringify(body)
1026
+ });
1027
+ const userData = await res.json();
1028
+ return toUser(userData);
1025
1029
  };
1026
1030
  var deleteUser = async (userId) => {
1027
- if (!isBrowser()) {
1028
- await adminFetch(`/admin/users/${userId}`, { method: "DELETE" });
1029
- return;
1030
- }
1031
- try {
1032
- const user = getAdminUser();
1033
- await user.admin.deleteUser({ id: userId });
1034
- } catch (error) {
1035
- if (error instanceof AuthError) throw error;
1036
- throw new AuthError(error.message, void 0, { cause: error });
1037
- }
1031
+ assertServer();
1032
+ const sanitizedUserId = sanitizeUserId(userId);
1033
+ await adminFetch(`/admin/users/${sanitizedUserId}`, { method: "DELETE" });
1038
1034
  };
1039
1035
  var admin = { listUsers, getUser: getUser2, createUser, updateUser: updateUser2, deleteUser };
1040
1036
  // Annotate the CommonJS export names for ESM import in node: