23 |
24 | ---
25 |
26 | The session data is stored in signed and encrypted cookies which are decoded by your server code in a stateless fashion (= no network involved). This is the same technique used by frameworks like
27 | [Ruby On Rails](https://guides.rubyonrails.org/security.html#session-storage).
28 |
29 |
;
115 | }
116 | ```
117 |
118 | ## Examples
119 |
120 | We have many different patterns and examples on the online demo, have a look: https://get-iron-session.vercel.app/.
121 |
122 | ## Project status
123 |
124 | ✅ Production ready and maintained.
125 |
126 | ## Session options
127 |
128 | Two options are required: `password` and `cookieName`. Everything else is automatically computed and usually doesn't need to be changed.****
129 |
130 | - `password`, **required**: Private key used to encrypt the cookie. It has to be at least 32 characters long. Use to generate strong passwords. `password` can be either a `string` or an `object` with incrementing keys like this: `{2: "...", 1: "..."}` to allow for password rotation. iron-session will use the highest numbered key for new cookies.
131 | - `cookieName`, **required**: Name of the cookie to be stored
132 | - `ttl`, _optional_: In seconds. Default to the equivalent of 14 days. You can set this to `0` and iron-session will compute the maximum allowed value by cookies.
133 | - `cookieOptions`, _optional_: Any option available from [jshttp/cookie#serialize](https://github.com/jshttp/cookie#cookieserializename-value-options) except for `encode` which is not a Set-Cookie Attribute. See [Mozilla Set-Cookie Attributes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes) and [Chrome Cookie Fields](https://developer.chrome.com/docs/devtools/application/cookies/#fields). Default to:
134 |
135 | ```js
136 | {
137 | httpOnly: true,
138 | secure: true, // set this to false in local (non-HTTPS) development
139 | sameSite: "lax",// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax
140 | maxAge: (ttl === 0 ? 2147483647 : ttl) - 60, // Expire cookie before the session expires.
141 | path: "/",
142 | }
143 | ```
144 |
145 | ## API
146 |
147 | ### `getIronSession(req, res, sessionOptions): Promise>`
148 |
149 | ```ts
150 | type SessionData = {
151 | // Your data
152 | }
153 |
154 | const session = await getIronSession(req, res, sessionOptions);
155 | ```
156 |
157 | ### `getIronSession(cookieStore, sessionOptions): Promise>`
158 |
159 | ```ts
160 | type SessionData = {
161 | // Your data
162 | }
163 |
164 | const session = await getIronSession(cookies(), sessionOptions);
165 | ```
166 |
167 | ### `session.save(): Promise`
168 |
169 | Saves the session. This is an asynchronous operation. It must be done and awaited before headers are sent to the client.
170 |
171 | ```ts
172 | await session.save()
173 | ```
174 |
175 | ### `session.destroy(): void`
176 |
177 | Destroys the session. This is a synchronous operation as it only removes the cookie. It must be done before headers are sent to the client.
178 |
179 | ```ts
180 | session.destroy()
181 | ```
182 |
183 | ### `session.updateConfig(sessionOptions: SessionOptions): void`
184 |
185 | Updates the configuration of the session with new session options. You still need to call save() if you want them to be applied.
186 |
187 | ### `sealData(data: unknown, { password, ttl }): Promise`
188 |
189 | This is the underlying method and seal mechanism that powers `iron-session`. You can use it to seal any `data` you want and pass it around. One usecase are magic links: you generate a seal that contains a user id to login and send it to a route on your website (like `/magic-login`). Once received, you can safely decode the seal with `unsealData` and log the user in.
190 |
191 | ### `unsealData(seal: string, { password, ttl }): Promise`
192 |
193 | This is the opposite of `sealData` and allow you to decode a seal to get the original data back.
194 |
195 | ## FAQ
196 |
197 | ### Why use pure cookies for sessions?
198 |
199 | This makes your sessions stateless: since the data is passed around in cookies, you do not need any server or service to store session data.
200 |
201 | More information can also be found on the [Ruby On Rails website](https://guides.rubyonrails.org/security.html#session-storage) which uses the same technique.
202 |
203 | ### How to invalidate sessions?
204 |
205 | Sessions cannot be instantly invalidated (or "disconnect this customer") as there is typically no state stored about sessions on the server by default. However, in most applications, the first step upon receiving an authenticated request is to validate the user and their permissions in the database. So, to easily disconnect customers (or invalidate sessions), you can add an `isBlocked`` state in the database and create a UI to block customers.
206 |
207 | Then, every time a request is received that involves reading or altering sensitive data, make sure to check this flag.
208 |
209 | ### Can I use something else than cookies?
210 |
211 | Yes, we expose `sealData` and `unsealData` which are not tied to cookies. This way you can seal and unseal any object in your application and move seals around to login users.
212 |
213 | ### How is this different from [JWT](https://jwt.io/)?
214 |
215 | Not so much:
216 |
217 | - JWT is a standard, it stores metadata in the JWT token themselves to ensure communication between different systems is flawless.
218 | - JWT tokens are not encrypted, the payload is visible by customers if they manage to inspect the seal. You would have to use [JWE](https://tools.ietf.org/html/rfc7516) to achieve the same.
219 | - @hapi/iron mechanism is not a standard, it's a way to sign and encrypt data into seals
220 |
221 | Depending on your own needs and preferences, `iron-session` may or may not fit you.
222 |
223 | ## Credits
224 |
225 | - [Eran Hammer and hapi.js contributors](https://github.com/hapijs/iron/graphs/contributors)
226 | for creating the underlying cryptography library
227 | [`@hapi/iron`](https://hapi.dev/module/iron/).
228 | - [Divyansh Singh](https://github.com/brc-dd) for reimplementing `@hapi/iron` as
229 | [`iron-webcrypto`](https://github.com/brc-dd/iron-webcrypto) using standard
230 | web APIs.
231 | - [Hoang Vo](https://github.com/hoangvvo) for advice and guidance while building
232 | this module. Hoang built
233 | [`next-connect`](https://github.com/hoangvvo/next-connect) and
234 | [`next-session`](https://github.com/hoangvvo/next-session).
235 | - All the
236 | [contributors](https://github.com/vvo/iron-session/graphs/contributors) for
237 | making this project better.
238 |
239 | ## Good Reads
240 |
241 | -
242 | -
243 |
--------------------------------------------------------------------------------
/examples/next/.env.development:
--------------------------------------------------------------------------------
1 | PUBLIC_URL=https://localhost:3000
2 | NEXT_PUBLIC_URL=https://localhost:3000
3 |
--------------------------------------------------------------------------------
/examples/next/.env.production:
--------------------------------------------------------------------------------
1 | PUBLIC_URL=https://$NEXT_PUBLIC_VERCEL_URL
2 | NEXT_PUBLIC_URL=https://$NEXT_PUBLIC_VERCEL_URL
3 |
--------------------------------------------------------------------------------
/examples/next/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "root": true,
4 | "settings": {
5 | "next": {
6 | "rootDir": "examples/next"
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/examples/next/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | certificates
--------------------------------------------------------------------------------
/examples/next/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | pnpm dev
9 | ```
10 |
11 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
12 |
13 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
14 |
15 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
16 |
17 | ## Learn More
18 |
19 | To learn more about Next.js, take a look at the following resources:
20 |
21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
23 |
24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
25 |
26 | ## Deploy on Vercel
27 |
28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
29 |
30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
31 |
--------------------------------------------------------------------------------
/examples/next/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | module.exports = nextConfig
5 |
--------------------------------------------------------------------------------
/examples/next/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev --experimental-https",
8 | "lint": "next lint",
9 | "start": "next start"
10 | },
11 | "dependencies": {
12 | "fathom-client": "3.6.0",
13 | "iron-session": "workspace:*",
14 | "next": "14.2.3",
15 | "react": "^18.3.1",
16 | "react-dom": "^18.3.1",
17 | "swr": "^2.2.5"
18 | },
19 | "devDependencies": {
20 | "@tailwindcss/forms": "^0.5.7",
21 | "@types/node": "20.14.2",
22 | "@types/react": "^18.3.3",
23 | "@types/react-dom": "^18.3.0",
24 | "autoprefixer": "^10.4.19",
25 | "eslint": "^8.54.0",
26 | "eslint-config-next": "14.2.3",
27 | "postcss": "^8.4.38",
28 | "tailwindcss": "^3.4.4",
29 | "typescript": "^5.4.5"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/examples/next/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/examples/next/public/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vvo/iron-session/abbd341e684598c04b07aa7874052c057927d9c2/examples/next/public/background.png
--------------------------------------------------------------------------------
/examples/next/public/github-mark-white.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/examples/next/public/github-mark.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/examples/next/src/app/GitHubLogo.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export default function GitHubLogo() {
4 | return (
5 | <>
6 |
13 |
20 | >
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/next/src/app/app-router-client-component-redirect-route-handler-fetch/form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as css from "@/app/css";
4 |
5 | import { useEffect, useState } from "react";
6 | import { SessionData } from "./lib";
7 | import { defaultSession } from "./lib";
8 |
9 | export function Form() {
10 | const [session, setSession] = useState(defaultSession);
11 | const [isLoading, setIsLoading] = useState(true);
12 |
13 | useEffect(() => {
14 | fetch("/app-router-client-component-redirect-route-handler-fetch/session")
15 | .then((res) => res.json())
16 | .then((session) => {
17 | setSession(session);
18 | setIsLoading(false);
19 | });
20 | }, []);
21 |
22 | if (isLoading) {
23 | return
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/examples/next/src/app/app-router-client-component-redirect-route-handler-fetch/lib.ts:
--------------------------------------------------------------------------------
1 | import { SessionOptions } from "iron-session";
2 |
3 | export interface SessionData {
4 | username: string;
5 | isLoggedIn: boolean;
6 | }
7 |
8 | export const defaultSession: SessionData = {
9 | username: "",
10 | isLoggedIn: false,
11 | };
12 |
13 | export const sessionOptions: SessionOptions = {
14 | password: "complex_password_at_least_32_characters_long",
15 | cookieName:
16 | "iron-examples-app-router-client-component-redirect-route-handler-fetch",
17 | cookieOptions: {
18 | // secure only works in `https` environments
19 | // if your localhost is not on `https`, then use: `secure: process.env.NODE_ENV === "production"`
20 | secure: true,
21 | },
22 | };
23 |
24 | export function sleep(ms: number) {
25 | return new Promise((resolve) => setTimeout(resolve, ms));
26 | }
27 |
--------------------------------------------------------------------------------
/examples/next/src/app/app-router-client-component-redirect-route-handler-fetch/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import * as css from "@/app/css";
3 |
4 | import { Metadata } from "next";
5 | import { Form } from "./form";
6 | import { Title } from "../title";
7 | import { GetTheCode } from "../../get-the-code";
8 |
9 | export const metadata: Metadata = {
10 | title:
11 | "🛠 iron-session examples: Client components, route handlers, redirects and fetch",
12 | };
13 |
14 | export default function AppRouterRedirect() {
15 | return (
16 |
17 |
18 |
19 |
20 | How to test: Login and refresh the page to see iron-session in
21 | action.
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | ← All examples
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | function HowItWorks() {
41 | return (
42 |
43 | How it works
44 |
45 |
46 |
47 | The form is submitted to
48 | /app-router-client-component-redirect-route-handler-fetch/session (API
49 | route) via a POST call (non-fetch). The API route sets the session
50 | data and redirects back to
51 | /app-router-client-component-redirect-route-handler-fetch (this page).
52 |
53 |
54 | The page gets the session data via a fetch call to
55 | /app-router-client-component-redirect-route-handler-fetch/session (API
56 | route). The API route either return the session data (logged in) or a
57 | default session (not logged in).
58 |
59 |
60 | The logout is a regular link navigating to
61 | /app-router-client-component-redirect-route-handler-fetch/logout which
62 | destroy the session and redirects back to
63 | /app-router-client-component-redirect-route-handler-fetch (this page).
64 |
65 |
66 |
67 |
68 | Pros: Straightforward. It does not rely on too many
69 | APIs. This is what we would have implemented a few years ago and is good
70 | enough for many websites.
71 |
72 |
73 | Cons: No synchronization. The session is not updated
74 | between tabs and windows. If you login or logout in one window or tab,
75 | the others are still showing the previous state. Also, we rely on full
76 | page navigation and redirects for login and logout. We could remove them
77 | by using fetch instead.
78 |
32 | How to test: Login and refresh the page to see iron-session in
33 | action. Bonus: open multiple tabs to see the state being reflected by
34 | SWR automatically.
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | The following pages are protected and will redirect back here if
43 | you're not logged in:
44 |
45 | {/* convert the following paragraphs into a ul li */}
46 |
85 |
86 | );
87 | }
88 |
89 | function HowItWorks() {
90 | return (
91 |
92 | How it works
93 |
94 |
95 |
96 | During login, the form is submitted with SWR's{" "}
97 |
101 | useSWRMutation
102 |
103 | . This makes a POST /session request using fetch.
104 |
105 |
106 | {" "}
107 | During logout, the form is submitted with SWR's{" "}
108 |
112 | useSWRMutation
113 |
114 | . This makes a DELETE /session request using fetch.
115 |
116 |
117 | In all other places, the content of the session is optimistally
118 | rendered using the most recent value, and never gets outdated. This is
119 | automatically handled by SWR using mutations and revalidation.
120 |
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/examples/next/src/app/app-router-client-component-route-handler-swr/protected-client/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Title } from "@/app/title";
4 | import useSession from "../use-session";
5 | import { useEffect } from "react";
6 | import { useRouter } from "next/navigation";
7 | import * as css from "@/app/css";
8 | import Link from "next/link";
9 |
10 | export default function ProtectedClient() {
11 | return (
12 |
13 |
14 |
15 |
47 | This page is protected and can only be accessed if you are logged in.
48 | Otherwise you will be redirected to the login page.
49 |
50 |
The check is done via a fetch call on the client using SWR.
51 |
52 | One benefit of using{" "}
53 |
54 | SWR
55 |
56 | : if you open the page in different tabs/windows, and logout from one
57 | place, every other tab/window will be synced and logged out.
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/examples/next/src/app/app-router-client-component-route-handler-swr/protected-middleware/page.tsx:
--------------------------------------------------------------------------------
1 | import { Title } from "@/app/title";
2 | import * as css from "@/app/css";
3 |
4 | import { cookies } from "next/headers";
5 | import { getIronSession } from "iron-session";
6 | import { SessionData, sessionOptions } from "../lib";
7 | import Link from "next/link";
8 |
9 | // Broken: None of these parameters is working, thus we have caching issues
10 | // TODO fix this
11 | export const dynamic = "force-dynamic";
12 | export const revalidate = 0;
13 |
14 | async function getSession() {
15 | const session = await getIronSession(cookies(), sessionOptions);
16 | return session;
17 | }
18 |
19 | export default function ProtectedServer() {
20 | return (
21 |
22 |
23 |
24 |
19 | How to test: Login and refresh the page to see iron-session in
20 | action.
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | ← All examples
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | function HowItWorks() {
40 | return (
41 |
42 | How it works
43 |
44 |
45 |
46 | The form is submitted to /app-router-magic-links/session (API route)
47 | via a POST call (non-fetch). The API route generates a sealed token
48 | and returns the magic link to client so it can be either sent or used
49 | right away. When the magic link is visited it sets the session data
50 | and redirects back to /app-router-magic-links (this page)
51 |
52 |
53 | The page gets the session data via a fetch call to
54 | /app-router-magic-links/session (API route). The API route either
55 | return the session data (logged in) or a default session (not logged
56 | in).
57 |
58 |
59 | The logout is a regular link navigating to
60 | /app-router-magic-links/logout which destroy the session and redirects
61 | back to /app-router-magic-links (this page).
62 |
63 |
64 |
65 |
66 | Pros: Simple.
67 |
68 |
69 | Cons: Dangerous if not used properly. Without any
70 | invalidations or blacklists, the magic link can be used multiple times
71 | if compromised.
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/examples/next/src/app/app-router-magic-links/session/route.ts:
--------------------------------------------------------------------------------
1 | import { getIronSession, sealData } from "iron-session";
2 | import { cookies } from "next/headers";
3 | import { redirect } from "next/navigation";
4 | import { NextRequest } from "next/server";
5 | import { SessionData, defaultSession, sessionOptions, sleep } from "../lib";
6 |
7 | // /app-router-magic-links/session
8 | export async function POST(request: NextRequest) {
9 | const formData = await request.formData();
10 | const username = formData.get("username") as string;
11 | const fifteenMinutesInSeconds = 15 * 60;
12 |
13 | const seal = await sealData(
14 | { username },
15 | {
16 | password: "complex_password_at_least_32_characters_long",
17 | ttl: fifteenMinutesInSeconds,
18 | },
19 | );
20 |
21 | // In a real application you would send back this data and use it to send an email
22 | // For the example purposes we will just display a webpage
23 | // return Response.json({
24 | // ok: true,
25 | // // Ideally this would be an email or text message with a link to the magic link route
26 | // magic_link: `${process.env.PUBLIC_URL}/app-router-magic-links/magic-login?seal=${seal}`,
27 | // });
28 | const link = `${process.env.NEXT_PUBLIC_URL}/app-router-magic-links/magic-login?seal=${seal}`;
29 | return new Response(
30 | `
10 | This website showcase different ways to use the iron-session library.
11 |
12 | Note: We've added delay to simulate database requests at login.
13 |
33 | How to test: Login and refresh the page to see iron-session in
34 | action. Bonus: open multiple tabs to see the state being reflected by
35 | SWR automatically.
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | The following pages are protected and will redirect back here if
44 | you're not logged in:
45 |
46 | {/* convert the following paragraphs into a ul li */}
47 |
84 |
85 | );
86 | }
87 |
88 | function HowItWorks() {
89 | return (
90 |
91 | How it works
92 |
93 |
94 |
95 | During login, the form is submitted with SWR's{" "}
96 |
100 | useSWRMutation
101 |
102 | . This makes a POST /session request using fetch.
103 |
104 |
105 | {" "}
106 | During logout, the form is submitted with SWR's{" "}
107 |
111 | useSWRMutation
112 |
113 | . This makes a DELETE /session request using fetch.
114 |
115 |
116 | In all other places, the content of the session is optimistally
117 | rendered using the most recent value, and never gets outdated. This is
118 | automatically handled by SWR using mutations and revalidation.
119 |
120 |
121 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/examples/next/src/pages/pages-router-api-route-swr/protected-client/index.tsx:
--------------------------------------------------------------------------------
1 | import { Title } from "@/app/title";
2 | import useSession from "@/pages-components/pages-router-api-route-swr/use-session";
3 | import { useEffect } from "react";
4 | import { useRouter } from "next/navigation";
5 | import * as css from "@/app/css";
6 | import Link from "next/link";
7 |
8 | export default function ProtectedClient() {
9 | return (
10 |
11 |
12 |
13 |
42 | This page is protected and can only be accessed if you are logged in.
43 | Otherwise you will be redirected to the login page.
44 |
45 |
The check is done via a fetch call on the client using SWR.
46 |
47 | One benefit of using{" "}
48 |
49 | SWR
50 |
51 | : if you open the page in different tabs/windows, and logout from one
52 | place, every other tab/window will be synced and logged out.
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/examples/next/src/pages/pages-router-api-route-swr/protected-middleware/index.tsx:
--------------------------------------------------------------------------------
1 | import { Title } from "@/app/title";
2 | import * as css from "@/app/css";
3 |
4 | import { getIronSession } from "iron-session";
5 | import {
6 | SessionData,
7 | sessionOptions,
8 | } from "@/pages-components/pages-router-api-route-swr/lib";
9 | import Link from "next/link";
10 | import type { InferGetServerSidePropsType, GetServerSideProps } from "next";
11 |
12 | export default function ProtectedServer({
13 | session,
14 | }: InferGetServerSidePropsType) {
15 | return (
16 |
17 |
18 |
19 |
56 | This page is protected and can only be accessed if you are logged in.
57 | Otherwise you will be redirected to the login page.
58 |
59 |
60 | getServerSideProps is used for the isLoggedIn check and to get the
61 | session data.
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/examples/next/src/pages/pages-router-redirect-api-route-fetch/index.tsx:
--------------------------------------------------------------------------------
1 | import { GetTheCode } from "@/get-the-code";
2 | import { Title } from "@/app/title";
3 | import * as css from "@/app/css";
4 | import Link from "next/link";
5 | import { Form } from "@/pages-components/pages-router-redirect-api-route-fetch/form";
6 | import Head from "next/head";
7 |
8 | export default function PagesRouterRedirect() {
9 | return (
10 |
11 |
12 |
13 | 🛠 iron-session examples: Pages Router, API routes, redirects and
14 | fetch
15 |
16 |
17 |
21 |
22 |
23 | How to test: Login and refresh the page to see iron-session in
24 | action.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ← All examples
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | function HowItWorks() {
44 | return (
45 |
46 | How it works
47 |
48 |
49 |
50 | The form is submitted to
51 | /api/pages-router-redirect-api-route-fetch/session (API route) via a
52 | POST call (non-fetch). The API route sets the session data and
53 | redirects back to /pages-router-redirect-api-route-fetch (this page).
54 |
55 |
56 | The page gets the session data via a fetch call to
57 | /api/pages-router-redirect-api-route-fetch/session (API route). The
58 | API route either return the session data (logged in) or a default
59 | session (not logged in).
60 |
61 |
62 | The logout is a regular link navigating to
63 | /api/pages-router-redirect-api-route-fetch/session?action=logout which
64 | destroy the session and redirects back to
65 | /pages-router-redirect-api-route-fetch (this page).
66 |
67 |
68 |
69 |
70 | Pros: Straightforward. It does not rely on too many
71 | APIs. This is what we would have implemented a few years ago and is good
72 | enough for many websites.
73 |
74 |
75 | Cons: No synchronization. The session is not updated
76 | between tabs and windows. If you login or logout in one window or tab,
77 | the others are still showing the previous state. Also, we rely on full
78 | page navigation and redirects for login and logout. We could remove them
79 | by using fetch instead.
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/examples/next/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 | import formsPlugin from "@tailwindcss/forms";
3 |
4 | const config: Config = {
5 | content: [
6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | plugins: [formsPlugin],
11 | };
12 | export default config;
13 |
--------------------------------------------------------------------------------
/examples/next/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | },
24 | "composite": true
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iron-session",
3 | "version": "8.0.4",
4 | "description": "Secure, stateless, and cookie-based session library for JavaScript",
5 | "keywords": [
6 | "session",
7 | "secure",
8 | "stateless",
9 | "cookie",
10 | "encryption",
11 | "security",
12 | "next.js",
13 | "node.js"
14 | ],
15 | "bugs": "https://github.com/vvo/iron-session/issues",
16 | "repository": "github:vvo/iron-session",
17 | "funding": [
18 | "https://github.com/sponsors/vvo",
19 | "https://github.com/sponsors/brc-dd"
20 | ],
21 | "license": "MIT",
22 | "author": "Vincent Voyer (https://github.com/vvo)",
23 | "sideEffects": false,
24 | "type": "module",
25 | "exports": {
26 | "import": "./dist/index.js",
27 | "require": "./dist/index.cjs"
28 | },
29 | "main": "./dist/index.cjs",
30 | "files": [
31 | "dist/*"
32 | ],
33 | "scripts": {
34 | "build": "tsup",
35 | "dev": "pnpm build && concurrently \"pnpm build --watch\" \"pnpm --filter=next-example dev\" ",
36 | "lint": "tsc --noEmit && tsc --noEmit -p examples/next/tsconfig.json && pnpm eslint . && publint",
37 | "prepare": "pnpm build && tsc --noEmit",
38 | "start": "turbo start --filter=next-example",
39 | "test": "c8 -r text -r lcov node --import tsx --test src/*.test.ts && pnpm build",
40 | "test:watch": "node --import tsx --test --watch src/*.test.ts"
41 | },
42 | "prettier": {
43 | "plugins": [
44 | "prettier-plugin-packagejson"
45 | ],
46 | "trailingComma": "all"
47 | },
48 | "dependencies": {
49 | "cookie": "^0.7.2",
50 | "iron-webcrypto": "^1.2.1",
51 | "uncrypto": "^0.1.3"
52 | },
53 | "devDependencies": {
54 | "@types/cookie": "0.6.0",
55 | "@types/node": "20.17.24",
56 | "@typescript-eslint/eslint-plugin": "7.18.0",
57 | "@typescript-eslint/parser": "7.18.0",
58 | "c8": "10.1.3",
59 | "concurrently": "8.2.2",
60 | "eslint": "8.57.1",
61 | "eslint-config-prettier": "9.1.0",
62 | "eslint-import-resolver-node": "0.3.9",
63 | "eslint-import-resolver-typescript": "3.9.1",
64 | "eslint-plugin-import": "2.31.0",
65 | "eslint-plugin-prettier": "5.2.5",
66 | "prettier": "3.5.3",
67 | "prettier-plugin-packagejson": "2.5.10",
68 | "publint": "0.3.9",
69 | "tsup": "8.4.0",
70 | "tsx": "4.19.3",
71 | "turbo": "^2.0.5",
72 | "typescript": "5.8.2"
73 | },
74 | "packageManager": "pnpm@9.6.0",
75 | "publishConfig": {
76 | "access": "public",
77 | "registry": "https://registry.npmjs.org"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - examples/*
3 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:recommended"],
4 | "packageRules": [
5 | {
6 | "matchUpdateTypes": ["minor", "patch"],
7 | "matchDepTypes": ["devDependencies"],
8 | "automerge": true
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/sponsor/clerk-dark.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/sponsor/clerk-light.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/core.ts:
--------------------------------------------------------------------------------
1 | import type { IncomingMessage, ServerResponse } from "http";
2 | import { parse, serialize, type CookieSerializeOptions } from "cookie";
3 | import {
4 | defaults as ironDefaults,
5 | seal as ironSeal,
6 | unseal as ironUnseal,
7 | } from "iron-webcrypto";
8 |
9 | type PasswordsMap = Record;
10 | type Password = PasswordsMap | string;
11 | type RequestType = IncomingMessage | Request;
12 | type ResponseType = Response | ServerResponse;
13 |
14 | /**
15 | * {@link https://wicg.github.io/cookie-store/#dictdef-cookielistitem CookieListItem}
16 | * as specified by W3C.
17 | */
18 | interface CookieListItem
19 | extends Pick<
20 | CookieSerializeOptions,
21 | "domain" | "path" | "sameSite" | "secure"
22 | > {
23 | /** A string with the name of a cookie. */
24 | name: string;
25 | /** A string containing the value of the cookie. */
26 | value: string;
27 | /** A number of milliseconds or Date interface containing the expires of the cookie. */
28 | expires?: CookieSerializeOptions["expires"] | number;
29 | }
30 |
31 | /**
32 | * Superset of {@link CookieListItem} extending it with
33 | * the `httpOnly`, `maxAge` and `priority` properties.
34 | */
35 | type ResponseCookie = CookieListItem &
36 | Pick;
37 |
38 | /**
39 | * The high-level type definition of the .get() and .set() methods
40 | * of { cookies() } from "next/headers"
41 | */
42 | export interface CookieStore {
43 | get: (name: string) => { name: string; value: string } | undefined;
44 | set: {
45 | (name: string, value: string, cookie?: Partial): void;
46 | (options: ResponseCookie): void;
47 | };
48 | }
49 |
50 | /**
51 | * Set-Cookie Attributes do not include `encode`. We omit this from our `cookieOptions` type.
52 | *
53 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
54 | * @see https://developer.chrome.com/docs/devtools/application/cookies/
55 | */
56 | type CookieOptions = Omit;
57 |
58 | export interface SessionOptions {
59 | /**
60 | * The cookie name that will be used inside the browser. Make sure it's unique
61 | * given your application.
62 | *
63 | * @example 'vercel-session'
64 | */
65 | cookieName: string;
66 |
67 | /**
68 | * The password(s) that will be used to encrypt the cookie. Can either be a string
69 | * or an object.
70 | *
71 | * When you provide multiple passwords then all of them will be used to decrypt
72 | * the cookie. But only the most recent (`= highest key`, `2` in the example)
73 | * password will be used to encrypt the cookie. This allows password rotation.
74 | *
75 | * @example { 1: 'password-1', 2: 'password-2' }
76 | */
77 | password: Password;
78 |
79 | /**
80 | * The time (in seconds) that the session will be valid for. Also sets the
81 | * `max-age` attribute of the cookie automatically (`= ttl - 60s`, so that the
82 | * cookie always expire before the session).
83 | *
84 | * `ttl = 0` means no expiration.
85 | *
86 | * @default 1209600
87 | */
88 | ttl?: number;
89 |
90 | /**
91 | * The options that will be passed to the cookie library.
92 | *
93 | * If you want to use "session cookies" (cookies that are deleted when the browser
94 | * is closed) then you need to pass `cookieOptions: { maxAge: undefined }`
95 | *
96 | * @see https://github.com/jshttp/cookie#options-1
97 | */
98 | cookieOptions?: CookieOptions;
99 | }
100 |
101 | export type IronSession = T & {
102 | /**
103 | * Encrypts the session data and sets the cookie.
104 | */
105 | readonly save: () => Promise;
106 |
107 | /**
108 | * Destroys the session data and removes the cookie.
109 | */
110 | readonly destroy: () => void;
111 |
112 | /**
113 | * Update the session configuration. You still need to call save() to send the new cookie.
114 | */
115 | readonly updateConfig: (newSessionOptions: SessionOptions) => void;
116 | };
117 |
118 | // default time allowed to check for iron seal validity when ttl passed
119 | // see https://hapi.dev/module/iron/api/?v=7.0.1#options
120 | const timestampSkewSec = 60;
121 | const fourteenDaysInSeconds = 14 * 24 * 3600;
122 |
123 | // We store a token major version to handle data format changes so that the cookies
124 | // can be kept alive between upgrades, no need to disconnect everyone.
125 | const currentMajorVersion = 2;
126 | const versionDelimiter = "~";
127 |
128 | const defaultOptions: Required> =
129 | {
130 | ttl: fourteenDaysInSeconds,
131 | cookieOptions: { httpOnly: true, secure: true, sameSite: "lax", path: "/" },
132 | };
133 |
134 | function normalizeStringPasswordToMap(password: Password): PasswordsMap {
135 | return typeof password === "string" ? { 1: password } : password;
136 | }
137 |
138 | function parseSeal(seal: string): {
139 | sealWithoutVersion: string;
140 | tokenVersion: number | null;
141 | } {
142 | const [sealWithoutVersion, tokenVersionAsString] =
143 | seal.split(versionDelimiter);
144 | const tokenVersion =
145 | tokenVersionAsString == null ? null : parseInt(tokenVersionAsString, 10);
146 |
147 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
148 | return { sealWithoutVersion: sealWithoutVersion!, tokenVersion };
149 | }
150 |
151 | function computeCookieMaxAge(ttl: number): number {
152 | if (ttl === 0) {
153 | // ttl = 0 means no expiration
154 | // but in reality cookies have to expire (can't have no max-age)
155 | // 2147483647 is the max value for max-age in cookies
156 | // see https://stackoverflow.com/a/11685301/147079
157 | return 2147483647;
158 | }
159 |
160 | // The next line makes sure browser will expire cookies before seals are considered expired by the server.
161 | // It also allows for clock difference of 60 seconds between server and clients.
162 | return ttl - timestampSkewSec;
163 | }
164 |
165 | function getCookie(req: RequestType, cookieName: string): string {
166 | return (
167 | parse(
168 | ("headers" in req && typeof req.headers.get === "function"
169 | ? req.headers.get("cookie")
170 | : (req as IncomingMessage).headers.cookie) ?? "",
171 | )[cookieName] ?? ""
172 | );
173 | }
174 |
175 | function getServerActionCookie(
176 | cookieName: string,
177 | cookieHandler: CookieStore,
178 | ): string {
179 | const cookieObject = cookieHandler.get(cookieName);
180 | const cookie = cookieObject?.value;
181 | if (typeof cookie === "string") {
182 | return cookie;
183 | }
184 | return "";
185 | }
186 |
187 | function setCookie(res: ResponseType, cookieValue: string): void {
188 | if ("headers" in res && typeof res.headers.append === "function") {
189 | res.headers.append("set-cookie", cookieValue);
190 | return;
191 | }
192 | let existingSetCookie = (res as ServerResponse).getHeader("set-cookie") ?? [];
193 | if (!Array.isArray(existingSetCookie)) {
194 | existingSetCookie = [existingSetCookie.toString()];
195 | }
196 | (res as ServerResponse).setHeader("set-cookie", [
197 | ...existingSetCookie,
198 | cookieValue,
199 | ]);
200 | }
201 |
202 | export function createSealData(_crypto: Crypto) {
203 | return async function sealData(
204 | data: unknown,
205 | {
206 | password,
207 | ttl = fourteenDaysInSeconds,
208 | }: { password: Password; ttl?: number },
209 | ): Promise {
210 | const passwordsMap = normalizeStringPasswordToMap(password);
211 |
212 | const mostRecentPasswordId = Math.max(
213 | ...Object.keys(passwordsMap).map(Number),
214 | );
215 | const passwordForSeal = {
216 | id: mostRecentPasswordId.toString(),
217 | secret: passwordsMap[mostRecentPasswordId]!,
218 | };
219 |
220 | const seal = await ironSeal(_crypto, data, passwordForSeal, {
221 | ...ironDefaults,
222 | ttl: ttl * 1000,
223 | });
224 |
225 | return `${seal}${versionDelimiter}${currentMajorVersion}`;
226 | };
227 | }
228 |
229 | export function createUnsealData(_crypto: Crypto) {
230 | return async function unsealData(
231 | seal: string,
232 | {
233 | password,
234 | ttl = fourteenDaysInSeconds,
235 | }: { password: Password; ttl?: number },
236 | ): Promise {
237 | const passwordsMap = normalizeStringPasswordToMap(password);
238 | const { sealWithoutVersion, tokenVersion } = parseSeal(seal);
239 |
240 | try {
241 | const data =
242 | (await ironUnseal(_crypto, sealWithoutVersion, passwordsMap, {
243 | ...ironDefaults,
244 | ttl: ttl * 1000,
245 | })) ?? {};
246 |
247 | if (tokenVersion === 2) {
248 | return data as T;
249 | }
250 |
251 | // @ts-expect-error `persistent` does not exist on newer tokens
252 | return { ...data.persistent } as T;
253 | } catch (error) {
254 | if (
255 | error instanceof Error &&
256 | /^(Expired seal|Bad hmac value|Cannot find password|Incorrect number of sealed components)/.test(
257 | error.message,
258 | )
259 | ) {
260 | // if seal expired or
261 | // if seal is not valid (encrypted using a different password, when passwords are badly rotated) or
262 | // if we can't find back the password in the seal
263 | // then we just start a new session over
264 | return {} as T;
265 | }
266 |
267 | throw error;
268 | }
269 | };
270 | }
271 |
272 | function getSessionConfig(
273 | sessionOptions: SessionOptions,
274 | ): Required {
275 | const options = {
276 | ...defaultOptions,
277 | ...sessionOptions,
278 | cookieOptions: {
279 | ...defaultOptions.cookieOptions,
280 | ...(sessionOptions.cookieOptions || {}),
281 | },
282 | };
283 |
284 | if (
285 | sessionOptions.cookieOptions &&
286 | "maxAge" in sessionOptions.cookieOptions
287 | ) {
288 | if (sessionOptions.cookieOptions.maxAge === undefined) {
289 | // session cookies, do not set maxAge, consider token as infinite
290 | options.ttl = 0;
291 | }
292 | } else {
293 | options.cookieOptions.maxAge = computeCookieMaxAge(options.ttl);
294 | }
295 |
296 | return options;
297 | }
298 |
299 | const badUsageMessage =
300 | "iron-session: Bad usage: use getIronSession(req, res, options) or getIronSession(cookieStore, options).";
301 |
302 | export function createGetIronSession(
303 | sealData: ReturnType,
304 | unsealData: ReturnType,
305 | ) {
306 | return getIronSession;
307 |
308 | async function getIronSession(
309 | cookies: CookieStore,
310 | sessionOptions: SessionOptions,
311 | ): Promise>;
312 | async function getIronSession(
313 | req: RequestType,
314 | res: ResponseType,
315 | sessionOptions: SessionOptions,
316 | ): Promise>;
317 | async function getIronSession(
318 | reqOrCookieStore: RequestType | CookieStore,
319 | resOrsessionOptions: ResponseType | SessionOptions,
320 | sessionOptions?: SessionOptions,
321 | ): Promise> {
322 | if (!reqOrCookieStore) {
323 | throw new Error(badUsageMessage);
324 | }
325 |
326 | if (!resOrsessionOptions) {
327 | throw new Error(badUsageMessage);
328 | }
329 |
330 | if (!sessionOptions) {
331 | return getIronSessionFromCookieStore(
332 | reqOrCookieStore as CookieStore,
333 | resOrsessionOptions as SessionOptions,
334 | sealData,
335 | unsealData,
336 | );
337 | }
338 |
339 | const req = reqOrCookieStore as RequestType;
340 | const res = resOrsessionOptions as ResponseType;
341 |
342 | if (!sessionOptions) {
343 | throw new Error(badUsageMessage);
344 | }
345 |
346 | if (!sessionOptions.cookieName) {
347 | throw new Error("iron-session: Bad usage. Missing cookie name.");
348 | }
349 |
350 | if (!sessionOptions.password) {
351 | throw new Error("iron-session: Bad usage. Missing password.");
352 | }
353 |
354 | const passwordsMap = normalizeStringPasswordToMap(sessionOptions.password);
355 |
356 | if (Object.values(passwordsMap).some((password) => password.length < 32)) {
357 | throw new Error(
358 | "iron-session: Bad usage. Password must be at least 32 characters long.",
359 | );
360 | }
361 |
362 | let sessionConfig = getSessionConfig(sessionOptions);
363 |
364 | const sealFromCookies = getCookie(req, sessionConfig.cookieName);
365 | const session = sealFromCookies
366 | ? await unsealData(sealFromCookies, {
367 | password: passwordsMap,
368 | ttl: sessionConfig.ttl,
369 | })
370 | : ({} as T);
371 |
372 | Object.defineProperties(session, {
373 | updateConfig: {
374 | value: function updateConfig(newSessionOptions: SessionOptions) {
375 | sessionConfig = getSessionConfig(newSessionOptions);
376 | },
377 | },
378 | save: {
379 | value: async function save() {
380 | if ("headersSent" in res && res.headersSent) {
381 | throw new Error(
382 | "iron-session: Cannot set session cookie: session.save() was called after headers were sent. Make sure to call it before any res.send() or res.end()",
383 | );
384 | }
385 |
386 | const seal = await sealData(session, {
387 | password: passwordsMap,
388 | ttl: sessionConfig.ttl,
389 | });
390 | const cookieValue = serialize(
391 | sessionConfig.cookieName,
392 | seal,
393 | sessionConfig.cookieOptions,
394 | );
395 |
396 | if (cookieValue.length > 4096) {
397 | throw new Error(
398 | `iron-session: Cookie length is too big (${cookieValue.length} bytes), browsers will refuse it. Try to remove some data.`,
399 | );
400 | }
401 |
402 | setCookie(res, cookieValue);
403 | },
404 | },
405 |
406 | destroy: {
407 | value: function destroy() {
408 | Object.keys(session).forEach((key) => {
409 | delete (session as Record)[key];
410 | });
411 | const cookieValue = serialize(sessionConfig.cookieName, "", {
412 | ...sessionConfig.cookieOptions,
413 | maxAge: 0,
414 | });
415 |
416 | setCookie(res, cookieValue);
417 | },
418 | },
419 | });
420 |
421 | return session as IronSession;
422 | }
423 | }
424 |
425 | async function getIronSessionFromCookieStore(
426 | cookieStore: CookieStore,
427 | sessionOptions: SessionOptions,
428 | sealData: ReturnType,
429 | unsealData: ReturnType,
430 | ): Promise> {
431 | if (!sessionOptions.cookieName) {
432 | throw new Error("iron-session: Bad usage. Missing cookie name.");
433 | }
434 |
435 | if (!sessionOptions.password) {
436 | throw new Error("iron-session: Bad usage. Missing password.");
437 | }
438 |
439 | const passwordsMap = normalizeStringPasswordToMap(sessionOptions.password);
440 |
441 | if (Object.values(passwordsMap).some((password) => password.length < 32)) {
442 | throw new Error(
443 | "iron-session: Bad usage. Password must be at least 32 characters long.",
444 | );
445 | }
446 |
447 | let sessionConfig = getSessionConfig(sessionOptions);
448 | const sealFromCookies = getServerActionCookie(
449 | sessionConfig.cookieName,
450 | cookieStore,
451 | );
452 | const session = sealFromCookies
453 | ? await unsealData(sealFromCookies, {
454 | password: passwordsMap,
455 | ttl: sessionConfig.ttl,
456 | })
457 | : ({} as T);
458 |
459 | Object.defineProperties(session, {
460 | updateConfig: {
461 | value: function updateConfig(newSessionOptions: SessionOptions) {
462 | sessionConfig = getSessionConfig(newSessionOptions);
463 | },
464 | },
465 | save: {
466 | value: async function save() {
467 | const seal = await sealData(session, {
468 | password: passwordsMap,
469 | ttl: sessionConfig.ttl,
470 | });
471 |
472 | const cookieLength =
473 | sessionConfig.cookieName.length +
474 | seal.length +
475 | JSON.stringify(sessionConfig.cookieOptions).length;
476 |
477 | if (cookieLength > 4096) {
478 | throw new Error(
479 | `iron-session: Cookie length is too big (${cookieLength} bytes), browsers will refuse it. Try to remove some data.`,
480 | );
481 | }
482 |
483 | cookieStore.set(
484 | sessionConfig.cookieName,
485 | seal,
486 | sessionConfig.cookieOptions,
487 | );
488 | },
489 | },
490 |
491 | destroy: {
492 | value: function destroy() {
493 | Object.keys(session).forEach((key) => {
494 | delete (session as Record)[key];
495 | });
496 |
497 | const cookieOptions = { ...sessionConfig.cookieOptions, maxAge: 0 };
498 | cookieStore.set(sessionConfig.cookieName, "", cookieOptions);
499 | },
500 | },
501 | });
502 |
503 | return session as IronSession;
504 | }
505 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import { deepEqual, doesNotMatch, equal, match, rejects } from "node:assert";
2 | import { mock, test } from "node:test";
3 | import type { IncomingMessage, ServerResponse } from "node:http";
4 | import type { SessionOptions } from "./index.js";
5 | import { getIronSession, sealData } from "./index.js";
6 |
7 | const password = "Gbm49ATjnqnkCCCdhV4uDBhbfnPqsCW0";
8 | const cookieName = "test";
9 |
10 | interface Data {
11 | user?: { id: number; meta?: string };
12 | }
13 |
14 | const getSession = async (
15 | req: IncomingMessage | Request,
16 | res: Response | ServerResponse,
17 | options: SessionOptions,
18 | ) => getIronSession(req, res, options);
19 |
20 | await test("should throw if the request parameter is missing", async () => {
21 | await rejects(
22 | // @ts-expect-error we're verifying JavaScript runtime checks here (DX)
23 | getSession(),
24 | "Error: iron-session: Bad usage: use getIronSession(req, res, options) or getIronSession(cookies, options).",
25 | );
26 | });
27 |
28 | await test("should throw if the response parameter is missing", async () => {
29 | await rejects(
30 | // @ts-expect-error we're verifying JavaScript runtime checks here (DX)
31 | getSession({}),
32 | "Error: iron-session: Bad usage: use getIronSession(req, res, options) or getIronSession(cookies, options).",
33 | );
34 | });
35 |
36 | await test("should throw if the cookie name is missing in options", async () => {
37 | await rejects(
38 | getSession({} as Request, {} as Response, {} as SessionOptions),
39 | /Missing cookie name/,
40 | );
41 | });
42 |
43 | await test("should throw if password is missing in options", async () => {
44 | await rejects(
45 | getSession({} as Request, {} as Response, { cookieName } as SessionOptions),
46 | /Missing password/,
47 | );
48 | });
49 |
50 | await test("should throw if password is less than 32 characters", async () => {
51 | await rejects(
52 | getSession({} as Request, {} as Response, {
53 | cookieName,
54 | password: "123456789012345678901234567890",
55 | }),
56 | /Password must be at least 32 characters long/,
57 | );
58 | });
59 |
60 | await test("should return blank session if no cookie is set", async () => {
61 | const session = await getSession({ headers: {} } as Request, {} as Response, {
62 | cookieName,
63 | password,
64 | });
65 | deepEqual(session, {});
66 | });
67 |
68 | await test("should set a cookie in the response object on save", async () => {
69 | const res = {
70 | getHeader: mock.fn(),
71 | setHeader: mock.fn(),
72 | };
73 |
74 | const session = await getSession(
75 | { headers: {} } as Request,
76 | res as unknown as ServerResponse,
77 | {
78 | cookieName,
79 | password,
80 | },
81 | );
82 | session.user = { id: 1 };
83 | await session.save();
84 |
85 | const [name, value] = res.setHeader.mock.calls[0]?.arguments ?? [];
86 | equal(name, "set-cookie");
87 | match(
88 | value[0],
89 | /^test=.{265}; Max-Age=1209540; Path=\/; HttpOnly; Secure; SameSite=Lax$/,
90 | );
91 |
92 | mock.reset();
93 | });
94 |
95 | await test("should allow deleting then saving session data", async () => {
96 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
97 |
98 | let session = await getSession(
99 | { headers: {} } as Request,
100 | res as unknown as ServerResponse,
101 | {
102 | cookieName,
103 | password,
104 | },
105 | );
106 | session.user = { id: 1 };
107 | await session.save();
108 |
109 | let cookie = res.setHeader.mock.calls[0]?.arguments[1][0].split(";")[0];
110 | session = await getSession(
111 | { headers: { cookie } } as IncomingMessage,
112 | res as unknown as ServerResponse,
113 | {
114 | cookieName,
115 | password,
116 | },
117 | );
118 | deepEqual(session, { user: { id: 1 } });
119 |
120 | delete session.user;
121 | await session.save();
122 |
123 | cookie = res.setHeader.mock.calls[1]?.arguments[1][0].split(";")[0];
124 | session = await getSession(
125 | { headers: { cookie } } as IncomingMessage,
126 | res as unknown as ServerResponse,
127 | {
128 | cookieName,
129 | password,
130 | },
131 | );
132 | deepEqual(session, {});
133 |
134 | mock.reset();
135 | });
136 |
137 | await test("should set max-age to a large number if ttl is 0", async () => {
138 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
139 |
140 | const session = await getSession(
141 | { headers: {} } as IncomingMessage,
142 | res as unknown as ServerResponse,
143 | {
144 | cookieName,
145 | password,
146 | ttl: 0,
147 | },
148 | );
149 | session.user = { id: 1 };
150 | await session.save();
151 |
152 | const cookie = res.setHeader.mock.calls[0]?.arguments[1][0];
153 | match(cookie, /Max-Age=2147483647;/);
154 |
155 | mock.reset();
156 | });
157 |
158 | await test("should respect provided max-age in cookie options", async () => {
159 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
160 | const options = { cookieName, password, cookieOptions: { maxAge: 60 } };
161 |
162 | const session = await getSession(
163 | { headers: {} } as IncomingMessage,
164 | res as unknown as ServerResponse,
165 | options,
166 | );
167 | session.user = { id: 1 };
168 | await session.save();
169 |
170 | const cookie = res.setHeader.mock.calls[0]?.arguments[1][0];
171 | match(cookie, /Max-Age=60;/);
172 |
173 | mock.reset();
174 | });
175 |
176 | await test("should not set max-age for session cookies", async () => {
177 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
178 | const options = {
179 | cookieName,
180 | password,
181 | cookieOptions: { maxAge: undefined },
182 | };
183 |
184 | const session = await getSession(
185 | { headers: {} } as IncomingMessage,
186 | res as unknown as ServerResponse,
187 | options,
188 | );
189 | session.user = { id: 1 };
190 | await session.save();
191 |
192 | const cookie = res.setHeader.mock.calls[0]?.arguments[1][0];
193 | doesNotMatch(cookie, /Max-Age/);
194 |
195 | mock.reset();
196 | });
197 |
198 | await test("should expire the cookie on destroying the session", async () => {
199 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
200 |
201 | const session = await getSession(
202 | { headers: {} } as IncomingMessage,
203 | res as unknown as ServerResponse,
204 | {
205 | cookieName,
206 | password,
207 | },
208 | );
209 | session.user = { id: 1 };
210 | await session.save();
211 |
212 | let cookie = res.setHeader.mock.calls[0]?.arguments[1][0];
213 | match(cookie, /Max-Age=1209540;/);
214 |
215 | deepEqual(session, { user: { id: 1 } });
216 | session.destroy();
217 | deepEqual(session, {});
218 |
219 | cookie = res.setHeader.mock.calls[1]?.arguments[1][0];
220 | match(cookie, /Max-Age=0;/);
221 |
222 | mock.reset();
223 | });
224 |
225 | await test("should reset the session if the seal is expired", async () => {
226 | const real = Date.now;
227 | Date.now = () => 0;
228 |
229 | const seal = await sealData({ user: { id: 1 } }, { password, ttl: 60 });
230 | const req = {
231 | headers: { cookie: `${cookieName}=${seal}` },
232 | } as IncomingMessage;
233 |
234 | let session = await getSession(req, {} as unknown as ServerResponse, {
235 | cookieName,
236 | password,
237 | });
238 | deepEqual(session, { user: { id: 1 } });
239 |
240 | Date.now = () => 120_000; // = ttl + 60s skew
241 |
242 | session = await getSession(req, {} as unknown as ServerResponse, {
243 | cookieName,
244 | password,
245 | });
246 | deepEqual(session, {});
247 |
248 | Date.now = real;
249 | });
250 |
251 | await test("should refresh the session (ttl, max-age) on save", async () => {
252 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
253 | const options = { cookieName, password, ttl: 61 };
254 |
255 | const real = Date.now;
256 | Date.now = () => 0;
257 |
258 | let session = await getSession(
259 | { headers: {} } as IncomingMessage,
260 | res as unknown as ServerResponse,
261 | options,
262 | );
263 | session.user = { id: 1 };
264 | await session.save();
265 |
266 | let cookie = res.setHeader.mock.calls[0]?.arguments[1][0];
267 | match(cookie, /Max-Age=1;/);
268 |
269 | Date.now = () => 120_000; // < ttl + 60s skew
270 |
271 | session = await getSession(
272 | { headers: { cookie: cookie.split(";")[0] } } as IncomingMessage,
273 | res as unknown as ServerResponse,
274 | options,
275 | );
276 | deepEqual(session, { user: { id: 1 } });
277 |
278 | await session.save(); // session is now valid for another ttl + 60s
279 |
280 | cookie = res.setHeader.mock.calls[1]?.arguments[1][0];
281 | match(cookie, /Max-Age=1;/); // max-age is relative to the current time
282 |
283 | Date.now = () => 240_000; // < earlier time + ttl + 60s skew
284 |
285 | session = await getSession(
286 | { headers: { cookie: cookie.split(";")[0] } } as IncomingMessage,
287 | res as unknown as ServerResponse,
288 | options,
289 | );
290 | deepEqual(session, { user: { id: 1 } }); // session is still valid
291 | // if ttl wasn't refreshed, session would have been reset to {}
292 |
293 | Date.now = real;
294 | mock.reset();
295 | });
296 |
297 | await test("should reset the session if password is changed", async () => {
298 | const firstPassword = password;
299 | const secondPassword = "12345678901234567890123456789012";
300 |
301 | const seal = await sealData({ user: { id: 1 } }, { password: firstPassword });
302 | const req = { headers: { cookie: `${cookieName}=${seal}` } };
303 |
304 | const session = await getSession(
305 | req as IncomingMessage,
306 | {} as unknown as ServerResponse,
307 | { cookieName, password: secondPassword },
308 | );
309 | deepEqual(session, {});
310 | });
311 |
312 | await test("should decrypt cookie generated from older password", async () => {
313 | const firstPassword = password;
314 | const secondPassword = "12345678901234567890123456789012";
315 |
316 | const seal = await sealData({ user: { id: 1 } }, { password: firstPassword });
317 | const req = { headers: { cookie: `${cookieName}=${seal}` } };
318 |
319 | const passwords = { 2: secondPassword, 1: firstPassword }; // rotation
320 | const session = await getSession(
321 | req as IncomingMessage,
322 | {} as unknown as ServerResponse,
323 | { cookieName, password: passwords },
324 | );
325 | deepEqual(session, { user: { id: 1 } });
326 | });
327 |
328 | await test("should throw if the cookie length is too big", async () => {
329 | const res = { getHeader: mock.fn(), setHeader: mock.fn() };
330 |
331 | const session = await getSession(
332 | { headers: {} } as IncomingMessage,
333 | res as unknown as ServerResponse,
334 | {
335 | cookieName,
336 | password,
337 | },
338 | );
339 | session.user = { id: 1, meta: "0".repeat(3000) };
340 | await rejects(session.save(), /Cookie length is too big/);
341 |
342 | mock.reset();
343 | });
344 |
345 | await test("should throw if trying to save after headers are sent", async () => {
346 | const session = await getSession(
347 | { headers: {} } as IncomingMessage,
348 | { headersSent: true } as unknown as Response,
349 | { cookieName, password },
350 | );
351 | session.user = { id: 1 };
352 |
353 | await rejects(
354 | session.save(),
355 | /session.save\(\) was called after headers were sent/,
356 | );
357 | });
358 |
359 | await test("should keep previously set cookie - single", async () => {
360 | const existingCookie = "existing=cookie";
361 | const res = {
362 | getHeader: mock.fn(() => existingCookie),
363 | setHeader: mock.fn(),
364 | };
365 |
366 | const session = await getSession(
367 | { headers: {} } as IncomingMessage,
368 | res as unknown as Response,
369 | {
370 | cookieName,
371 | password,
372 | },
373 | );
374 | session.user = { id: 1 };
375 | await session.save();
376 |
377 | let cookies = res.setHeader.mock.calls[0]?.arguments[1];
378 | deepEqual(cookies[0], existingCookie);
379 | deepEqual(cookies.length, 2);
380 |
381 | session.destroy();
382 |
383 | cookies = res.setHeader.mock.calls[1]?.arguments[1];
384 | deepEqual(cookies[0], existingCookie);
385 | deepEqual(
386 | cookies[1],
387 | `${cookieName}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`,
388 | );
389 |
390 | mock.reset();
391 | });
392 |
393 | await test("should keep previously set cookies - multiple", async () => {
394 | const existingCookies = ["existing=cookie", "existing2=cookie2"];
395 | const res = {
396 | getHeader: mock.fn(() => existingCookies),
397 | setHeader: mock.fn(),
398 | };
399 |
400 | const session = await getSession(
401 | { headers: {} } as Request,
402 | res as unknown as Response,
403 | {
404 | cookieName,
405 | password,
406 | },
407 | );
408 | session.user = { id: 1 };
409 | await session.save();
410 |
411 | let cookies = res.setHeader.mock.calls[0]?.arguments[1];
412 | deepEqual(cookies[0], existingCookies[0]);
413 | deepEqual(cookies[1], existingCookies[1]);
414 | deepEqual(cookies.length, 3);
415 |
416 | session.destroy();
417 |
418 | cookies = res.setHeader.mock.calls[1]?.arguments[1];
419 | deepEqual(cookies[0], existingCookies[0]);
420 | deepEqual(cookies[1], existingCookies[1]);
421 | deepEqual(
422 | cookies[2],
423 | `${cookieName}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`,
424 | );
425 |
426 | mock.reset();
427 | });
428 |
429 | await test("should be backwards compatible with older cookie format", async () => {
430 | // this seal is in the old next-iron-session format (generated with ttl: 0)
431 | const cookie = `${cookieName}=Fe26.2*1*1e2bacee1edffaeb4a9ba4a07dc36c2c60d20415a60ac1b901033af1f107ead5*LAC9Fn3BJ9ifKMhVL3pP5w*JHhcByIzk4ThLt9rUW-fDMrOwUT7htHy1uyqeOTIqrVwDJ0Bz7TOAwIz_Cos-ug3**7dfa11868bbcc4f7e118342c0280ff49ba4a7cc84c70395bbc3d821a5f460174*6a8FkHxdg322jyym6PwJf3owz7pd6nq5ZIzyLHGVC0c`;
432 |
433 | const session = await getSession(
434 | { headers: { cookie } } as IncomingMessage,
435 | {} as Response,
436 | { cookieName, password },
437 | );
438 | deepEqual(session, { user: { id: 77 } });
439 | });
440 |
441 | await test("should prevent reassignment of save/destroy functions", async () => {
442 | const session = await getSession(
443 | { headers: {} } as IncomingMessage,
444 | {} as Response,
445 | { cookieName, password },
446 | );
447 |
448 | await rejects(async () => {
449 | // @ts-expect-error Runtime check
450 | session.save = () => {};
451 | }, /Cannot assign to read only property 'save' of object '#