Skip to content

Commit

Permalink
feat(lib): new major version (#7)
Browse files Browse the repository at this point in the history
* feat(lib): new major version

complete rewrite using iron-store
changed the API to ease usage on Next.js

BREAKING CHANGES

* fix linting

* update readme

* update readme and pkg description
  • Loading branch information
vvo authored Mar 9, 2020
1 parent 59febb4 commit 4e2d6fa
Show file tree
Hide file tree
Showing 6 changed files with 316 additions and 279 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/.history/
/.yarn/
/.vscode/
/dist/
Expand Down
151 changes: 79 additions & 72 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,114 +1,121 @@
# iron-session [![GitHub license](https://img.shields.io/github/license/vvo/iron-session?style=flat)](https://github.com/vvo/iron-session/blob/master/LICENSE) ![Tests](https://github.com/vvo/iron-session/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/vvo/iron-session/branch/master/graph/badge.svg)](https://codecov.io/gh/vvo/iron-session) ![npm](https://img.shields.io/npm/v/iron-session)
# next-iron-session [![GitHub license](https://img.shields.io/github/license/vvo/next-iron-session?style=flat)](https://github.com/vvo/next-iron-session/blob/master/LICENSE) ![Tests](https://github.com/vvo/next-iron-session/workflows/Tests/badge.svg) [![codecov](https://codecov.io/gh/vvo/next-iron-session/branch/master/graph/badge.svg)](https://codecov.io/gh/vvo/next-iron-session) ![npm](https://img.shields.io/npm/v/next-iron-session)

**This JavaScript backend utility** allows you to create a session to then be stored in browser cookies via a signed and encrypted token value. This provides client sessions that are ⚒️ iron-strong.
_🛠 Next.js stateless session utility using signed and encrypted cookies to store data_

The token stored on the client contains the session data, not your server, making it a "stateless" session from the server point of view. The token is signed and encrypted using [@hapi/iron](https://github.com/hapijs/iron).
---

**⚡️ Flash session data is supported**. It means you can store some data which will be deleted when read. This is useful for temporary tokens, redirects or notices on your UI.
**This [Next.js](https://nextjs.org/) backend utility** allows you to create a session to then be stored in browser cookies via a signed and encrypted seal. This provides client sessions that are ⚒️ iron-strong.

**By default the cookie has an ⏰ expiration time of 15 days**, set via [`maxAge`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Directives). After that, even if someone tries to reuse the cookie, @hapi/iron will not accept the underlying token. Because the expiration is also part of the token value. See https://hapi.dev/family/iron for more information on @hapi/iron mechanisms.
The seal stored on the client contains the session data, not your server, making it a "stateless" session from the server point of view. This is a different take than [next-session](https://github.com/hoangvvo/next-session/) where the cookie contains a session ID to then be used to identity data on the server-side.

**Why use pure 🍪 cookies for sessions?** This makes your sessions stateless: you do not have to store session data on your server. This is particularly useful in serverless architectures. Still, there are some drawbacks to this approach:
The seal is signed and encrypted using [@hapi/iron](https://github.com/hapijs/iron), [iron-store](https://github.com/vvo/iron-store/) is used behind the scenes.

- you cannot invalidate a cookie when needed because there's no state stored on the server-side about the tokens. We consider that the way the cookie is stored reduces the possibility for this eventuality to happen.
- application not supporting cookies won't work, this could be solved in the future by exposing the underlying token instead of signed and encrypted cookies. Open an issue if you're interested.
- on most browsers, you're limited to 4,096 bytes per cookie. To give you an idea, an `iron-session` containing `{user: {id: 230, admin: true}}` is 358 bytes signed and encrypted: still plenty of available cookie space in here.
**⚡️ Flash session data is supported**. It means you can store some data which will be deleted when read. This is useful for temporary data, redirects or notices on your UI.

Now that you know the drawbacks, you can decide if they are an issue for your application or not.

**🤓 References:**

- https://owasp.org/www-project-cheat-sheets/cheatsheets/Session_Management_Cheat_Sheet.html#cookies
- https://owasp.org/www-project-cheat-sheets/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#encryption-based-token-pattern

## How is this different from [JWT](https://jwt.io/)?
**By default the cookie has an ⏰ expiration time of 15 days**, set via [`maxAge`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Directives). After that, even if someone tries to reuse the cookie, `next-iron-session` will not accept the underlying seal because the expiration is part of the seal value. See https://hapi.dev/family/iron for more information on @hapi/iron mechanisms.

Not so much:

- JWT is a standard, it stores metadata in the JWT token themselves to ensure communication between different systems is flawless.
- JWT tokens are not encrypted, the payload is visible by customers if they manage to inspect the token. You would have to use [JWE](https://tools.ietf.org/html/rfc7516) to achieve the same.
- @hapi/iron mechanism is not a standard, it's a way to sign and encrypt data into tokens

Depending on your own needs and preferences, `iron-session-cookie` may or may not fit you.

## Instalation
## Installation

```bash
npm add iron-session
npm add next-iron-session
```

## Usage

The examples are using a user login flow: login, verify, log out. But you can use `iron-session` for any other session need.
The password is a private key you must pass at runtime, it has to be at least 32 characters long. Use https://1password.com/password-generator/ to generate strong passwords.

The password is a private key you must pass at runtime, it has to be at least 32 characters long. https://1password.com/password-generator/ is a good way to generate a strong password.
Store passwords in secret environment variables on your platform.

### When the user logs in
**login.js**:

```js
import { createSession } from "iron-session";
export default async (req, res) => {
// when user successfully logs in using email/password, oauth, ... then we create a session
// const user = ...
import withIronSession from "iron-session";

const session = await createSession({
password: process.env.SECRET_SESSION_PASSWORD
});

session.set({ name: "user", value: { id: 230, admin: true } });
session.set({ name: "message", value: "Login success", flash: true });

res.writeHead(200, {
"set-cookie": await session.serializeCookie()
async function handler(req, res, session) {
session.set("user", {
id: 230,
admin: true
});
await session.save();
res.send("Logged in");
}

res.end("ok");
};
export default withIronSession(handler, {
password: "complex_password_at_least_32_characters_long"
});
```

`serializeCookie` accepts all the options from https://github.com/jshttp/cookie#cookieserializename-value-options, merged with `iron-session` defaults. The defaults are:
**user.js**:

```js
{
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: (ttl === 0 ? 2147483647 : ttl) - 60, // For Iron, ttl 0 means it will never expire. For browser cookies, maxAge 0 means it will expire immediately. WhilCookie must expire before the seal, otherwise you could have expired seals stored in a cookie
import withIronSession from "iron-session";

function handler(req, res, session) {
const user = session.get("user");
res.send({ user });
}

export default withIronSession(handler, {
password: "complex_password_at_least_32_characters_long"
});
```

### Checking if the user is logged in
**logout.js**:

```js
import { getSession, parseCookie } from "iron-session";
export default async (req, res) => {
const session = await getSession({
password: process.env.SECRET_SESSION_PASSWORD,
sealed: parseCookie({ cookie: req.getHeader("cookie") })
});
import withIronSession from "iron-session";

const user = session.get({ name: "user" });
const flashMessage = session.get({ name: "message" });
function handler(req, res, session) {
session.destroy();
res.send("Logged out");
}

res.end("ok");
};
export default withIronSession(handler, {
password: "complex_password_at_least_32_characters_long"
});
```

### When the user logs out
## API

```js
import { deleteCookie } from "iron-session";
export default async (req, res) => {
res.writeHead(200, {
"set-cookie": deleteCookie()
});
### withIronSession(handler, {password, ttl, cookieName, cookieOptions})

res.end("ok");
};
```
### session.set

### session.get

### session.setFlash

### session.destroy

## FAQ

### Why use pure 🍪 cookies for sessions?

This makes your sessions stateless: you do not have to store session data on your server. This is particularly useful in serverless architectures. Still, there are some drawbacks to this approach:

- you cannot invalidate a seal when needed because there's no state stored on the server-side about them. We consider that the way the cookie is stored reduces the possibility for this eventuality to happen.
- application not supporting cookies won't work, but you can use [iron-store](https://github.com/vvo/iron-store/) to implement something similar. In the future we could allow `next-iron-session` to accept [basic auth](https://tools.ietf.org/html/rfc7617) or bearer token methods too. Open an issue if you're interested.
- on most browsers, you're limited to 4,096 bytes per cookie. To give you an idea, a `next-iron-session` cookie containing `{user: {id: 230, admin: true}}` is 358 bytes signed and encrypted: still plenty of available cookie space in here.

Now that you know the drawbacks, you can decide if they are an issue for your application or not.

### How is this different from [JWT](https://jwt.io/)?

Not so much:

- JWT is a standard, it stores metadata in the JWT seal themselves to ensure communication between different systems is flawless.
- JWT seals 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.
- @hapi/iron mechanism is not a standard, it's a way to sign and encrypt data into seals

Depending on your own needs and preferences, `next-iron-session-cookie` may or may not fit you.

## Project status

This is a recent library I authored because I needed it. While @hapi/iron is battle-tested and [used in production on a lot of websites](https://hapi.dev/), this library is not. Please use it at your own risk.

If you find bugs or have API ideas, create an issue.

## 🤓 References

- https://owasp.org/www-project-cheat-sheets/cheatsheets/Session_Management_Cheat_Sheet.html#cookies
- https://owasp.org/www-project-cheat-sheets/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#encryption-based-seal-pattern
114 changes: 56 additions & 58 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,69 +1,67 @@
import Iron from "@hapi/iron";
import ironStore from "iron-store";
import cookie from "cookie";
import clone from "clone";

const defaultTtl = 15 * 24 * 3600;
const cookieName = "__ironSession";
// TODO: warn on no session usage
// TODO: warn when session saved after end
// TODO: warn when session not saved after set and req ended

export async function createSession({ password, ttl = defaultTtl }) {
return getSession({ password, ttl });
// default time allowed to check for iron seal validity when ttl passed
// see https://hapi.dev/family/iron/api/?v=6.0.0#options
const timestampSkewSec = 60;

function throwOnNoPassword() {
throw new Error("next-iron-sesion: Missing parameter `password`");
}

export async function getSession({ sealed, password, ttl = defaultTtl }) {
const options = { ...Iron.defaults, ttl };
const store =
sealed !== undefined
? await Iron.unseal(sealed, password, options)
: { persistent: {}, flash: {} };
function computeCookieMaxAge(ttl) {
return (ttl === 0 ? 2147483647 : ttl) - timestampSkewSec;
}

return {
set({ name, value, flash = false }) {
if (flash === true) {
store.flash[name] = clone(value);
} else {
store.persistent[name] = clone(value);
}
},
get({ name = undefined } = {}) {
if (name === undefined) {
const flash = store.flash;
store.flash = {};
return clone({
...flash,
...store.persistent
});
}
const defaultCookieOptions = {
httpOnly: true,
secure: true,
sameSite: "lax"
};

if (store.flash[name] !== undefined) {
const value = store.flash[name];
delete store.flash[name];
return value; // no need to clone, we removed the reference from the flash store
} else {
return clone(store.persistent[name]);
}
},
async serializeCookie(cookieOptions = {}) {
return cookie.serialize(
cookieName,
await Iron.seal(store, password, options),
{
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: (ttl === 0 ? 2147483647 : ttl) - 60, // For Iron, ttl 0 means it will never expire. For browser cookies, maxAge 0 means it will expire immediately. WhilCookie must expire before the seal, otherwise you could have expired seals stored in a cookie
...cookieOptions
}
);
}
export default function withIronSession(
withIronSessionWrapperHandler,
{
ttl = 15 * 24 * 3600,
cookieName = "__ironSession",
password = throwOnNoPassword(),
cookieOptions: userCookieOptions = {}
} = {}
) {
const cookieOptions = {
...defaultCookieOptions,
...userCookieOptions,
maxAge: userCookieOptions.maxAge || computeCookieMaxAge(ttl)
};
}

export function parseCookie({ cookie: cookieValue }) {
return cookie.parse(cookieValue)[cookieName];
}
return async function withIronSessionHandler(req, res) {
const store = await ironStore({
sealed: req.cookies[cookieName],
password,
ttl
});

const session = {
set: store.set,
get: store.get,
setFlash: store.setFlash,
async save() {
const seal = await store.seal();
const cookieValue = cookie.serialize(cookieName, seal, cookieOptions);
res.setHeader("set-cookie", [cookieValue]);
},
destroy() {
const cookieValue = cookie.serialize(cookieName, "", {
maxAge: 0
});
res.setHeader("set-cookie", [cookieValue]);
}
};

export function deleteCookie() {
return cookie.serialize(cookieName, "", {
maxAge: 0
});
return withIronSessionWrapperHandler(req, res, session);
};
}
Loading

0 comments on commit 4e2d6fa

Please sign in to comment.