@netlify/identity 0.4.2 → 1.1.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.
package/README.md CHANGED
@@ -3,33 +3,35 @@
3
3
  A lightweight, no-config headless authentication library for projects using Netlify Identity. Works in both browser and server contexts.
4
4
  This is NOT the Netlify Identity Widget. This library exports standalone async functions (e.g., import { login, getUser } from '@netlify/identity'). There is no class to instantiate and no .init() call. Just import the functions you need and call them.
5
5
 
6
- > **Status:** Beta. The API may change before 1.0.
7
-
8
6
  **Prerequisites:**
9
7
 
10
- - [Netlify Identity](https://docs.netlify.com/security/secure-access-to-sites/identity/) must be enabled on your Netlify project
8
+ - [Netlify Identity](https://docs.netlify.com/security/secure-access-to-sites/identity/) must be enabled on your Netlify project. This happens automatically when running within a [Netlify Agent Runner](https://docs.netlify.com/agent-runner/overview/)
11
9
  - **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**
12
10
  - For local development, use [`netlify dev`](https://docs.netlify.com/cli/local-development/) so the Identity endpoint is available
13
11
 
14
12
  ## How this library relates to other Netlify auth packages
15
13
 
16
- | Package | What it is | When to use it |
17
- | ------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ |
18
- | **`@netlify/identity`** (this library) | Headless TypeScript API for browser and server | You want full control over your auth UI and need server-side auth (SSR, Netlify Functions) |
19
- | [`netlify-identity-widget`](https://github.com/netlify/netlify-identity-widget) | Pre-built login/signup modal (HTML + CSS) | You want a drop-in UI component with no custom design |
20
- | [`gotrue-js`](https://github.com/netlify/gotrue-js) | Low-level GoTrue HTTP client (browser only) | You're building your own auth wrapper and need direct API access |
14
+ `@netlify/identity` is the recommended library for all new projects. It works in both browser and server contexts, handles cookie management, and normalizes the user object.
15
+
16
+ You may encounter two older packages in existing code or documentation:
17
+
18
+ | Package | Status | What it was |
19
+ | ------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------- |
20
+ | [`netlify-identity-widget`](https://github.com/netlify/netlify-identity-widget) | Not recommended for new projects | Pre-built login/signup modal with built-in UI |
21
+ | [`gotrue-js`](https://github.com/netlify/gotrue-js) | Not recommended for new projects | Low-level GoTrue HTTP client (browser only) |
21
22
 
22
- This library provides a unified API that works in both browser and server contexts, handles cookie management, and normalizes the user object. You do not need to install `gotrue-js` or the widget separately.
23
+ If you need a pre-built login UI, the widget still works. For everything else (custom UI, server-side auth, admin operations, framework integration), use `@netlify/identity`.
23
24
 
24
25
  ## Table of contents
25
26
 
26
27
  - [Installation](#installation)
27
28
  - [Quick start](#quick-start)
28
29
  - [API](#api)
29
- - [Functions](#functions) -- `getUser`, `login`, `signup`, `logout`, `oauthLogin`, `handleAuthCallback`, `onAuthChange`, `hydrateSession`, `refreshSession`, and more
30
+ - [Functions](#functions) -- `getUser`, `login`, `signup`, `logout`, `oauthLogin`, `handleAuthCallback`, `onAuthChange`, `hydrateSession`, `refreshSession`, `verifyRequestOrigin`, and more
30
31
  - [Admin Operations](#admin-operations) -- `admin.listUsers`, `admin.getUser`, `admin.createUser`, `admin.updateUser`, `admin.deleteUser`
31
- - [Types](#types) -- `User`, `AuthEvent`, `CallbackResult`, `Settings`, `Admin`, `ListUsersOptions`, `CreateUserParams`, etc.
32
+ - [Types](#types) -- `User`, `AuthEvent`, `CallbackResult`, `Settings`, `Admin`, `ListUsersOptions`, `CreateUserParams`, `VerifyRequestOriginOptions`, etc.
32
33
  - [Errors](#errors) -- `AuthError`, `MissingIdentityError`
34
+ - [Security: CSRF protection](#security-csrf-protection)
33
35
  - [Framework integration](#framework-integration) -- Next.js, Remix, TanStack Start, Astro, SvelteKit
34
36
  - [Guides](#guides)
35
37
  - [React `useAuth` hook](#react-useauth-hook)
@@ -97,7 +99,7 @@ export default async (req: Request, context: Context) => {
97
99
  getUser(): Promise<User | null>
98
100
  ```
99
101
 
100
- Returns the current authenticated user, or `null` if not logged in. Returns the best available normalized `User` from the current context. In the browser or when the server can reach the Identity API, all fields are populated. When falling back to JWT claims (e.g., Identity API unreachable), fields like `createdAt`, `updatedAt`, `emailVerified`, and `rawGoTrueData` may be missing. Never throws.
102
+ Returns the current authenticated user, or `null` if not logged in. Returns the best available normalized `User` from the current context. When the Identity API is reachable, most persisted and profile fields are populated, but state-dependent fields (invite, recovery, email-change) may still be `undefined` if the user is not in that state. When falling back to JWT claims (e.g., Identity API unreachable), only `id`, `email`, `provider`, `name`, `pictureUrl`, `roles`, `userMetadata`, and `appMetadata` are available. Never throws.
101
103
 
102
104
  > **Next.js note:** Calling `getUser()` in a Server Component opts the page into [dynamic rendering](https://nextjs.org/docs/app/building-your-application/rendering/server-components#dynamic-rendering) because it reads cookies. This is expected and correct for authenticated pages. Next.js handles the internal dynamic rendering signal automatically.
103
105
 
@@ -173,7 +175,7 @@ oauthLogin(provider: string): never
173
175
 
174
176
  Redirects to an OAuth provider. The page navigates away, so this function never returns normally. Browser only.
175
177
 
176
- The `provider` argument should be one of the `AuthProvider` values: `'google'`, `'github'`, `'gitlab'`, `'bitbucket'`, `'facebook'`, or `'saml'`.
178
+ The `provider` argument should be one of the `AuthProvider` values: `'google'`, `'github'`, `'gitlab'`, `'bitbucket'`, or `'facebook'`.
177
179
 
178
180
  **Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if called on the server.
179
181
 
@@ -241,6 +243,20 @@ export async function onRequest(context, next) {
241
243
  }
242
244
  ```
243
245
 
246
+ #### `verifyRequestOrigin`
247
+
248
+ ```ts
249
+ verifyRequestOrigin(request: Request, options?: VerifyRequestOriginOptions): void
250
+ ```
251
+
252
+ CSRF protection helper for server-side endpoints that call `login()`, `signup()`, or `logout()`. Compares the request's `Origin` header against the request's own origin (or an explicit allowlist via `options.allowedOrigins`) and throws if they don't match. Server-only.
253
+
254
+ The check runs unconditionally on every call: any HTTP method, with or without an `Origin` header. If you don't want the check on a particular route, don't call the helper there.
255
+
256
+ **Throws:** `AuthError` with status `403` when the request has no `Origin` header. `AuthError` with status `403` when the request's `Origin` is not in the allowed origins.
257
+
258
+ See [Security: CSRF protection](#security-csrf-protection) for the full threat model and per-framework guidance.
259
+
244
260
  #### `requestPasswordRecovery`
245
261
 
246
262
  ```ts
@@ -361,7 +377,7 @@ Gets a single user by ID.
361
377
  admin.createUser(params: CreateUserParams): Promise<User>
362
378
  ```
363
379
 
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`.
380
+ Creates a new user. The user is auto-confirmed. Optional `data` forwards allowed fields (`role`, `app_metadata`, `user_metadata`) to the request body. Other keys are silently ignored. `data` cannot override `email`, `password`, or `confirm`.
365
381
 
366
382
  **Throws:** `AuthError` if called from a browser, the email already exists, or the operator token is missing.
367
383
 
@@ -393,16 +409,22 @@ Deletes a user by ID.
393
409
  interface User {
394
410
  id: string
395
411
  email?: string
396
- emailVerified?: boolean
412
+ confirmedAt?: string
397
413
  createdAt?: string
398
414
  updatedAt?: string
415
+ role?: string
399
416
  provider?: AuthProvider
400
417
  name?: string
401
418
  pictureUrl?: string
402
419
  roles?: string[]
403
- metadata?: Record<string, unknown>
420
+ invitedAt?: string
421
+ confirmationSentAt?: string
422
+ recoverySentAt?: string
423
+ pendingEmail?: string
424
+ emailChangeSentAt?: string
425
+ lastSignInAt?: string
426
+ userMetadata?: Record<string, unknown>
404
427
  appMetadata?: Record<string, unknown>
405
- rawGoTrueData?: Record<string, unknown>
406
428
  }
407
429
  ```
408
430
 
@@ -428,7 +450,7 @@ interface IdentityConfig {
428
450
  #### `AuthProvider`
429
451
 
430
452
  ```ts
431
- type AuthProvider = 'google' | 'github' | 'gitlab' | 'bitbucket' | 'facebook' | 'saml' | 'email'
453
+ type AuthProvider = 'google' | 'github' | 'gitlab' | 'bitbucket' | 'facebook' | 'email'
432
454
  ```
433
455
 
434
456
  #### `UserUpdates`
@@ -451,14 +473,13 @@ interface AdminUserUpdates {
451
473
  email?: string
452
474
  password?: string
453
475
  role?: string
454
- aud?: string
455
476
  confirm?: boolean
456
477
  app_metadata?: Record<string, unknown>
457
478
  user_metadata?: Record<string, unknown>
458
479
  }
459
480
  ```
460
481
 
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.
482
+ Fields accepted by `admin.updateUser()`. Unlike `UserUpdates`, admin updates can set `role`, force-confirm a user, and write to `app_metadata`. Only these typed fields are forwarded.
462
483
 
463
484
  #### `SignupData`
464
485
 
@@ -499,7 +520,7 @@ interface CreateUserParams {
499
520
  }
500
521
  ```
501
522
 
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.
523
+ Parameters for `admin.createUser()`. Optional `data` forwards allowed fields (`role`, `app_metadata`, `user_metadata`) to the request body. Other keys are silently ignored.
503
524
 
504
525
  #### `Admin`
505
526
 
@@ -563,6 +584,16 @@ The `token` field is only present for `invite` callbacks, where the user hasn't
563
584
 
564
585
  For all other types (`oauth`, `confirmation`, `recovery`, `email_change`), the user is logged in directly and `token` is not set.
565
586
 
587
+ #### `VerifyRequestOriginOptions`
588
+
589
+ ```ts
590
+ interface VerifyRequestOriginOptions {
591
+ allowedOrigins?: string[]
592
+ }
593
+ ```
594
+
595
+ Options for [`verifyRequestOrigin`](#verifyrequestorigin). When `allowedOrigins` is set, the list replaces the default same-origin check, so include the request's own origin if you still want it allowed. Each value is a full origin string with scheme and host (`'https://example.com'`).
596
+
566
597
  ### Errors
567
598
 
568
599
  #### `AuthError`
@@ -582,6 +613,45 @@ class MissingIdentityError extends Error {}
582
613
 
583
614
  Thrown when Identity is not configured in the current environment.
584
615
 
616
+ ## Security: CSRF protection
617
+
618
+ If you expose server-side `login()`, `signup()`, or `logout()` through an HTTP endpoint, that endpoint needs Cross-Site Request Forgery (CSRF) protection. The library cannot enforce this itself because it only sees the email and password arguments handed to it, not the incoming request.
619
+
620
+ **Why it matters.** A specific flavor called _login CSRF_ lets an attacker trick a victim's browser into logging into the attacker's account. The victim then performs actions inside that session (saving payment info, linking third-party services, uploading content), and the attacker harvests the result later by signing in with the credentials they always controlled. `SameSite=Lax` cookies do not catch this attack because the session is being created on the victim's browser, not ridden from an existing one.
621
+
622
+ ### `verifyRequestOrigin`
623
+
624
+ `verifyRequestOrigin(request, options?)` compares the request's `Origin` header against the request's own origin (or an explicit allowlist) and throws `AuthError` with status 403 on mismatch. Call it at the start of any handler that performs an auth mutation.
625
+
626
+ ```ts
627
+ // netlify/functions/login.ts
628
+ import { login, verifyRequestOrigin } from '@netlify/identity'
629
+ import type { Context } from '@netlify/functions'
630
+
631
+ export default async (req: Request, context: Context) => {
632
+ verifyRequestOrigin(req)
633
+ const { email, password } = await req.json()
634
+ await login(email, password)
635
+ return new Response(null, { status: 302, headers: { Location: '/dashboard' } })
636
+ }
637
+ ```
638
+
639
+ The helper runs unconditionally on every call. It checks any HTTP method, with or without an `Origin` header. If you don't want the check on a particular route, don't call the helper there.
640
+
641
+ ### Custom allowed origins
642
+
643
+ By default, the helper accepts only the request's own origin. Pass `allowedOrigins` to allow additional trusted origins (for example, a separate frontend domain that POSTs to an API on another domain). The list replaces the default, so include the request's own origin if you still want it allowed:
644
+
645
+ ```ts
646
+ verifyRequestOrigin(req, {
647
+ allowedOrigins: ['https://app.example.com', 'https://www.example.com'],
648
+ })
649
+ ```
650
+
651
+ ### When to call the helper
652
+
653
+ Some frameworks check the request's `Origin` on state-changing requests by default; others don't. Check your framework's documentation. If same-origin enforcement is already on by default for the endpoint where you invoke `login()` / `signup()` / `logout()`, calling `verifyRequestOrigin` yourself is redundant. If it isn't, call `verifyRequestOrigin(request)` at the start of the handler before invoking the auth function.
654
+
585
655
  ## Framework integration
586
656
 
587
657
  ### Recommended pattern for SSR frameworks
@@ -655,11 +725,12 @@ Use `window.location.href` instead of Next.js `redirect()` after server-side aut
655
725
 
656
726
  ```tsx
657
727
  // app/routes/login.tsx
658
- import { login } from '@netlify/identity'
728
+ import { login, verifyRequestOrigin } from '@netlify/identity'
659
729
  import { redirect, json } from '@remix-run/node'
660
730
  import type { ActionFunctionArgs } from '@remix-run/node'
661
731
 
662
732
  export async function action({ request }: ActionFunctionArgs) {
733
+ verifyRequestOrigin(request)
663
734
  const formData = await request.formData()
664
735
  const email = formData.get('email') as string
665
736
  const password = formData.get('password') as string
@@ -687,6 +758,8 @@ export async function loader() {
687
758
 
688
759
  Remix `redirect()` works after server-side `login()` because Remix actions return real HTTP responses. The browser receives a 302 with the `Set-Cookie` header already applied, so the next request includes the auth cookie. This is different from Next.js, where `redirect()` in a Server Action triggers a client-side (soft) navigation that may not include newly-set cookies.
689
760
 
761
+ > The example calls [`verifyRequestOrigin`](#verifyrequestorigin) at the top of the action. See [Security: CSRF protection](#security-csrf-protection) for when this is needed.
762
+
690
763
  ### TanStack Start
691
764
 
692
765
  **Login from the browser (recommended):**
package/dist/index.cjs CHANGED
@@ -51,12 +51,13 @@ __export(index_exports, {
51
51
  requestPasswordRecovery: () => requestPasswordRecovery,
52
52
  signup: () => signup,
53
53
  updateUser: () => updateUser,
54
- verifyEmailChange: () => verifyEmailChange
54
+ verifyEmailChange: () => verifyEmailChange,
55
+ verifyRequestOrigin: () => verifyRequestOrigin
55
56
  });
56
57
  module.exports = __toCommonJS(index_exports);
57
58
 
58
59
  // src/types.ts
59
- var AUTH_PROVIDERS = ["google", "github", "gitlab", "bitbucket", "facebook", "saml", "email"];
60
+ var AUTH_PROVIDERS = ["google", "github", "gitlab", "bitbucket", "facebook", "email"];
60
61
 
61
62
  // src/environment.ts
62
63
  var import_gotrue_js = __toESM(require("gotrue-js"), 1);
@@ -681,6 +682,7 @@ var hydrateSession = async () => {
681
682
 
682
683
  // src/user.ts
683
684
  var toAuthProvider = (value) => typeof value === "string" && AUTH_PROVIDERS.includes(value) ? value : void 0;
685
+ var toOptionalString = (value) => typeof value === "string" && value !== "" ? value : void 0;
684
686
  var toRoles = (appMeta) => {
685
687
  const roles = appMeta.roles;
686
688
  if (Array.isArray(roles) && roles.every((r) => typeof r === "string")) {
@@ -693,20 +695,25 @@ var toUser = (userData) => {
693
695
  const appMeta = userData.app_metadata ?? {};
694
696
  const name = userMeta.full_name || userMeta.name;
695
697
  const pictureUrl = userMeta.avatar_url;
696
- const { token: _token, ...safeUserData } = userData;
697
698
  return {
698
699
  id: userData.id,
699
700
  email: userData.email,
700
- emailVerified: !!userData.confirmed_at,
701
+ confirmedAt: toOptionalString(userData.confirmed_at),
701
702
  createdAt: userData.created_at,
702
703
  updatedAt: userData.updated_at,
704
+ role: toOptionalString(userData.role),
703
705
  provider: toAuthProvider(appMeta.provider),
704
706
  name: typeof name === "string" ? name : void 0,
705
707
  pictureUrl: typeof pictureUrl === "string" ? pictureUrl : void 0,
706
708
  roles: toRoles(appMeta),
707
- metadata: userMeta,
708
- appMetadata: appMeta,
709
- rawGoTrueData: { ...safeUserData }
709
+ invitedAt: toOptionalString(userData.invited_at),
710
+ confirmationSentAt: toOptionalString(userData.confirmation_sent_at),
711
+ recoverySentAt: toOptionalString(userData.recovery_sent_at),
712
+ pendingEmail: toOptionalString(userData.new_email),
713
+ emailChangeSentAt: toOptionalString(userData.email_change_sent_at),
714
+ lastSignInAt: toOptionalString(userData.last_sign_in_at),
715
+ userMetadata: userMeta,
716
+ appMetadata: appMeta
710
717
  };
711
718
  };
712
719
  var claimsToUser = (claims) => {
@@ -721,7 +728,7 @@ var claimsToUser = (claims) => {
721
728
  name: typeof name === "string" ? name : void 0,
722
729
  pictureUrl: typeof pictureUrl === "string" ? pictureUrl : void 0,
723
730
  roles: toRoles(appMeta),
724
- metadata: userMeta,
731
+ userMetadata: userMeta,
725
732
  appMetadata: appMeta
726
733
  };
727
734
  };
@@ -818,8 +825,7 @@ var getSettings = async () => {
818
825
  gitlab: external.gitlab ?? false,
819
826
  bitbucket: external.bitbucket ?? false,
820
827
  facebook: external.facebook ?? false,
821
- email: external.email ?? false,
822
- saml: external.saml ?? false
828
+ email: external.email ?? false
823
829
  }
824
830
  };
825
831
  } catch (err) {
@@ -827,6 +833,18 @@ var getSettings = async () => {
827
833
  }
828
834
  };
829
835
 
836
+ // src/csrf.ts
837
+ var verifyRequestOrigin = (request, options) => {
838
+ const origin = request.headers.get("origin");
839
+ if (!origin) {
840
+ throw new AuthError("Cross-origin request refused: missing Origin header.", 403);
841
+ }
842
+ const allowed = options?.allowedOrigins ?? [new URL(request.url).origin];
843
+ if (!allowed.includes(origin)) {
844
+ throw new AuthError(`Cross-origin request refused: Origin ${origin} did not match an allowed origin.`, 403);
845
+ }
846
+ };
847
+
830
848
  // src/account.ts
831
849
  var resolveCurrentUser = async () => {
832
850
  const client = getClient();
@@ -996,7 +1014,7 @@ var createUser = async (params) => {
996
1014
  confirm: true
997
1015
  };
998
1016
  if (params.data) {
999
- const allowedKeys = ["role", "aud", "app_metadata", "user_metadata"];
1017
+ const allowedKeys = ["role", "app_metadata", "user_metadata"];
1000
1018
  for (const key of allowedKeys) {
1001
1019
  if (key in params.data) {
1002
1020
  body[key] = params.data[key];
@@ -1014,7 +1032,7 @@ var updateUser2 = async (userId, attributes) => {
1014
1032
  assertServer();
1015
1033
  const sanitizedUserId = sanitizeUserId(userId);
1016
1034
  const body = {};
1017
- const allowedKeys = ["email", "password", "role", "aud", "confirm", "app_metadata", "user_metadata"];
1035
+ const allowedKeys = ["email", "password", "role", "confirm", "app_metadata", "user_metadata"];
1018
1036
  for (const key of allowedKeys) {
1019
1037
  if (key in attributes) {
1020
1038
  body[key] = attributes[key];
@@ -1056,6 +1074,7 @@ var admin = { listUsers, getUser: getUser2, createUser, updateUser: updateUser2,
1056
1074
  requestPasswordRecovery,
1057
1075
  signup,
1058
1076
  updateUser,
1059
- verifyEmailChange
1077
+ verifyEmailChange,
1078
+ verifyRequestOrigin
1060
1079
  });
1061
1080
  //# sourceMappingURL=index.cjs.map