@netlify/identity 1.0.0 → 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
@@ -27,10 +27,11 @@ If you need a pre-built login UI, the widget still works. For everything else (c
27
27
  - [Installation](#installation)
28
28
  - [Quick start](#quick-start)
29
29
  - [API](#api)
30
- - [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
31
31
  - [Admin Operations](#admin-operations) -- `admin.listUsers`, `admin.getUser`, `admin.createUser`, `admin.updateUser`, `admin.deleteUser`
32
- - [Types](#types) -- `User`, `AuthEvent`, `CallbackResult`, `Settings`, `Admin`, `ListUsersOptions`, `CreateUserParams`, etc.
32
+ - [Types](#types) -- `User`, `AuthEvent`, `CallbackResult`, `Settings`, `Admin`, `ListUsersOptions`, `CreateUserParams`, `VerifyRequestOriginOptions`, etc.
33
33
  - [Errors](#errors) -- `AuthError`, `MissingIdentityError`
34
+ - [Security: CSRF protection](#security-csrf-protection)
34
35
  - [Framework integration](#framework-integration) -- Next.js, Remix, TanStack Start, Astro, SvelteKit
35
36
  - [Guides](#guides)
36
37
  - [React `useAuth` hook](#react-useauth-hook)
@@ -242,6 +243,20 @@ export async function onRequest(context, next) {
242
243
  }
243
244
  ```
244
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
+
245
260
  #### `requestPasswordRecovery`
246
261
 
247
262
  ```ts
@@ -569,6 +584,16 @@ The `token` field is only present for `invite` callbacks, where the user hasn't
569
584
 
570
585
  For all other types (`oauth`, `confirmation`, `recovery`, `email_change`), the user is logged in directly and `token` is not set.
571
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
+
572
597
  ### Errors
573
598
 
574
599
  #### `AuthError`
@@ -588,6 +613,45 @@ class MissingIdentityError extends Error {}
588
613
 
589
614
  Thrown when Identity is not configured in the current environment.
590
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
+
591
655
  ## Framework integration
592
656
 
593
657
  ### Recommended pattern for SSR frameworks
@@ -661,11 +725,12 @@ Use `window.location.href` instead of Next.js `redirect()` after server-side aut
661
725
 
662
726
  ```tsx
663
727
  // app/routes/login.tsx
664
- import { login } from '@netlify/identity'
728
+ import { login, verifyRequestOrigin } from '@netlify/identity'
665
729
  import { redirect, json } from '@remix-run/node'
666
730
  import type { ActionFunctionArgs } from '@remix-run/node'
667
731
 
668
732
  export async function action({ request }: ActionFunctionArgs) {
733
+ verifyRequestOrigin(request)
669
734
  const formData = await request.formData()
670
735
  const email = formData.get('email') as string
671
736
  const password = formData.get('password') as string
@@ -693,6 +758,8 @@ export async function loader() {
693
758
 
694
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.
695
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
+
696
763
  ### TanStack Start
697
764
 
698
765
  **Login from the browser (recommended):**
package/dist/index.cjs CHANGED
@@ -51,7 +51,8 @@ __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
 
@@ -832,6 +833,18 @@ var getSettings = async () => {
832
833
  }
833
834
  };
834
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
+
835
848
  // src/account.ts
836
849
  var resolveCurrentUser = async () => {
837
850
  const client = getClient();
@@ -1061,6 +1074,7 @@ var admin = { listUsers, getUser: getUser2, createUser, updateUser: updateUser2,
1061
1074
  requestPasswordRecovery,
1062
1075
  signup,
1063
1076
  updateUser,
1064
- verifyEmailChange
1077
+ verifyEmailChange,
1078
+ verifyRequestOrigin
1065
1079
  });
1066
1080
  //# sourceMappingURL=index.cjs.map