Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change session and token handling #42

Merged
merged 19 commits into from
Jan 3, 2020
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The `auth()` middleware has a few configuration keys that are required for initi
- **`baseURL`** - The root URL for the application router. This can be set automatically with a `BASE_URL` variable in your environment.
- **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment.
- **`issuerBaseURL`** - The root URL for the token issuer with no trailing slash. In Auth0, this is your Application's **Domain** prepended with `https://`. This can be set automatically with an `ISSUER_BASE_URL` variable in your environment.
- **`appSessionSecret`** - The secret used to derive an encryption key for the user identity in a session cookie. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `APP_SESSION_SECRET` variable in your environment. It must be a string or an array of strings. When array is provided the first member is used for signing and other members can be used for decrypting old cookies, this is to enable appSessionSecret rotation.

If you are using a response type that includes `code` (typically combined with an `audience` parameter), you will need an additional key:

Expand All @@ -33,6 +34,9 @@ Additional configuration keys that can be passed to `auth()` on initialization:
- **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`.
- **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`.
- **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`.
- **`appSessionLength`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days.
- **`appSessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`.
- **`appSessionCookie`** - Object defining application session cookie attributes. Allowed keys are `domain`, `httpOnly`, `path`, `secure`, and `sameSite`. Defaults are `true` for `httpOnly` and `Lax` for `sameSite`.

### Authorization Params Key

Expand Down Expand Up @@ -100,12 +104,12 @@ This library adds properties and methods to the request and response objects use

### Request

Every request object (typically named `req` in your route handler) is augmented with the following when the request is authenticated. If the request is not authenticated, `req.openid` is `undefined`.
Every request object (typically named `req` in your route handler) is augmented with the following when the request is authenticated. If the request is not authenticated, `req.openid` is `undefined` and `req.isAuthenticated()` returns `false`.

- **`req.openid.user`** - Contains the user information returned from the authorization server. You can change what is provided here by using the `getUser` configuration key.
- **`req.openid.tokens`** - Is the [TokenSet](https://github.com/panva/node-openid-client/blob/master/docs/README.md#tokenset) instance obtained during login.
- **`req.openid.user`** - Contains the user information returned from the authorization server. You can change what is provided here by passing a function to the `getUser` configuration key.
- **`req.openid.client`** - Is the [OpenID Client](https://github.com/panva/node-openid-client/blob/master/docs/README.md#client) instance that can be used for additional OAuth2 and OpenID calls. See [the examples](EXAMPLES.md) for more information on how this is used.
- **`req.isAuthenticated()`** - Returns true if the request is authenticated.
- **`req.makeTokenSet()`** - Make a TokenSet object from a JSON representation of one.

### Response

Expand Down
127 changes: 91 additions & 36 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,12 @@ The simplest use case for this middleware:
ISSUER_BASE_URL=https://YOUR_DOMAIN
CLIENT_ID=YOUR_CLIENT_ID
BASE_URL=https://YOUR_APPLICATION_ROOT_URL
SESSION_NAME=YOUR_SESSION_NAME
COOKIE_SECRET=LONG_RANDOM_VALUE
APP_SESSION_SECRET=LONG_RANDOM_STRING
```

```javascript
// app.js
const { auth } = require('express-openid-connect');
const session = require('cookie-session');

app.use(express.urlencoded({ extended: false }));

app.use(session({
name: process.env.SESSION_NAME,
secret: process.env.COOKIE_SECRET
}));

app.use(auth({
required: true
Expand Down Expand Up @@ -65,7 +56,6 @@ Another way to configure this scenario:
```js
const { auth } = require('express-openid-connect');

//initialization
app.use(auth({
required: req => req.originalUrl.startsWith('/admin/')
}));
Expand Down Expand Up @@ -98,7 +88,78 @@ app.use(auth({

Please note that both of these routes are completely optional and not required. Trying to access any protected resource triggers a redirect directly to Auth0 to login.

## 4. Using refresh tokens
## 4. Using access tokens

If your application needs to request and store access tokens, you must provide a method to store the incoming tokens during callback. We recommend to use a persistant store, like a database or Redis, to store these tokens directly associated with the user for which they were requested.

If the tokens only need to be used during the user's session, they can be stored using a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all tokens will be lost when the server restarts. The basics of handling the tokens is below:

```js
const session = require('express-session');
app.use(session({
secret: 'replace this with a long, random, static string',
cookie: {
// Sets the session cookie to expire after 7 days.
maxAge: 7 * 24 * 60 * 60 * 1000
}
}));

app.use(auth({
authorizationParams: {
response_type: 'code',
audience: process.env.API_URL,
scope: 'openid profile email read:reports'
},
handleCallback: async function (req, res, next) {
req.session.openIdTokens = req.openIdTokens;
next();
}
}));
```

On a route that needs to use the access token, pull the token data from the storage and initialize a new `TokenSet` using `makeTokenSet()` method exposed by this library:

```js
app.get('/route-that-calls-an-api', async (req, res, next) => {

const tokenSet = req.openid.makeTokenSet(req.session.openIdTokens);
let apiData = {};

// Check for and use tokenSet.access_token for the API call ...
});
```

## 5. Custom user session handling

By default, this library uses an encrypted cookie to store the user identity claims used as a session. If the size of the user identity is too large or you're concerned about sensitive data being stored, you can provide your own session handling as part of the `getUser` function.

If, for example, you want the user session to be stored on the server, you can use a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all sessions will be lost when the server restarts. The basics of handling the user identity server-side is below:

```js
const session = require('express-session');
app.use(session({
secret: 'replace this with a long, random, static string',
joshcanhelp marked this conversation as resolved.
Show resolved Hide resolved
cookie: {
// Sets the session cookie to expire after 7 days.
maxAge: 7 * 24 * 60 * 60 * 1000
}
}));

app.use(auth({
// Setting this configuration key to false will turn off internal session handling.
appSessionSecret: false,
handleCallback: async function (req, res, next) {
// This will store the user identity claims in the session
req.session.userIdentity = req.openIdTokens.claims();
next();
},
getUser: async function (req) {
return req.session.userIdentity;
}
}));
```

## 6. Using refresh tokens

Refresh tokens can be requested along with access tokens using the `offline_access` scope during login:

Expand All @@ -107,8 +168,14 @@ app.use(auth({
authorizationParams: {
response_type: 'code id_token',
response_mode: 'form_post',
// API identifier to indicate which API this application will be calling.
audience: process.env.API_URL,
// Include the required scopes as well as offline_access to generate a refresh token.
scope: 'openid profile email read:reports offline_access'
},
handleCallback: async function (req, res, next) {
// See the "Using access tokens" section above for token handling.
next();
}
}));
```
Expand All @@ -119,39 +186,30 @@ On a route that calls an API, check for an expired token and attempt a refresh:
app.get('/route-that-calls-an-api', async (req, res, next) => {

let apiData = {};
let tokenSet = req.openid.tokens;

if (tokenSet && tokenSet.expired() && tokenSet.refresh_token) {
// How the tokenSet is created will depend on how the tokens are stored.
let tokenSet = req.openid.makeTokenSet(req.session.openIdTokens);
let refreshToken = tokenSet.refresh_token;

if (tokenSet && tokenSet.expired() && refreshToken) {
try {
tokenSet = await req.openid.client.refresh(tokenSet);
} catch(err) {
next(err);
}

tokenSet.refresh_token = req.openid.tokens.refresh_token;
req.openid.tokens = tokenSet;
}
// New tokenSet may not include a new refresh token.
tokenSet.refresh_token = tokenSet.refresh_token ?? refreshToken;

try {
apiData = await request(
process.env.API_URL,
{
headers: { authorization: `Bearer ${tokenSet.access_token}` },
json: true
}
);
} catch(err) {
next(err);
// Where you store the refreshed tokenSet will depend on how the tokens are stored.
req.session.openIdTokens = tokenSet;
}

res.render('api-data-template', {
user: req.openid && req.openid.user,
apiData
});
// Check for and use tokenSet.access_token for the API call ...
});
```

## 5. Calling userinfo
## 7. Calling userinfo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a better title to match the access token one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I struggled with a title here because you only need to call userinfo in specific cases and I'm not sure if I could shorten that down to something universally-clear in the title. I added a bit more color in the description, though.


If your application needs to call the userinfo endpoint for the user's identity, add a `handleCallback` function during initialization that will make this call. To map the incoming claims to the user identity, also add a `getUser` function.

Expand All @@ -160,15 +218,12 @@ app.use(auth({
handleCallback: async function (req, res, next) {
const client = req.openid.client;
try {
req.session.userinfo = await client.userinfo(req.session.openidTokens);
req.identity.claims = await client.userinfo(req.openidTokens);
next();
} catch(e) {
next(e);
}
},
getUser: function(tokenSet) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not need this now? The descriptive text indicates we do but it looks like line 221 is mapping claims directly now. One of these seems out of step with the other.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. That example was not working properly with this new system.

return tokenSet && ( tokenSet.userinfo || tokenSet.claims() );
},
authorizationParams: {
response_type: 'code',
scope: 'openid profile email'
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,25 @@ These can be configured in a `.env` file in the root of your application:
ISSUER_BASE_URL=https://YOUR_DOMAIN
CLIENT_ID=YOUR_CLIENT_ID
BASE_URL=https://YOUR_APPLICATION_ROOT_URL
APP_SESSION_SECRET=LONG_RANDOM_VALUE
```

... or in the library initialization:

```js
// index.js

const { auth } = require('express-openid-connect');
app.use(auth({
required: true,
issuerBaseURL: 'https://YOUR_DOMAIN',
baseURL: 'https://YOUR_APPLICATION_ROOT_URL',
clientID: 'YOUR_CLIENT_ID'
clientID: 'YOUR_CLIENT_ID',
appSessionKey: 'LONG_RANDOM_STRING'
}));
```

See [Examples](EXAMPLES.md) for how to get started authenticating users.
See the [Examples](EXAMPLES.md) for how to get started authenticating users.

## Contributing

Expand Down
88 changes: 88 additions & 0 deletions lib/appSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const { strict: assert } = require('assert');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Encryption added and reviewed by security here:

https://github.com/joshcanhelp/express-openid-connect/pull/1


const { JWK, JWKS, JWE } = require('jose');
const onHeaders = require('on-headers');
const cookie = require('cookie');
const hkdf = require('futoin-hkdf');

const deriveKey = (secret) => hkdf(secret, 32, { info: 'JWE CEK', hash: 'SHA-256' });
const epoch = () => Date.now() / 1000 | 0;

module.exports = ({ name, secret, duration, cookieOptions = {} }) => {
let current;

const COOKIES = Symbol('cookies');
const alg = 'dir';
const enc = 'A256GCM';

let keystore = new JWKS.KeyStore();

if (!Array.isArray(secret)) {
secret = [secret];
}

secret.forEach((secretString, i) => {
const key = JWK.asKey(deriveKey(secretString));
if (i === 0) {
current = key;
}
keystore.add(key);
});

if (keystore.size === 1) {
keystore = current;
}

function encrypt (payload, headers) {
return JWE.encrypt(payload, current, { alg, enc, zip: 'DEF', ...headers });
}

function decrypt (jwe) {
return JWE.decrypt(jwe, keystore, { complete: true, algorithms: [enc] });
}

function setCookie (req, res, { uat = epoch(), iat = uat, exp = uat + duration }) {
if ((!req[name] || !Object.keys(req[name]).length) && name in req[COOKIES]) {
res.clearCookie(name);
return;
}

if (req[name] && Object.keys(req[name]).length > 0) {
const value = encrypt(JSON.stringify(req[name]), { iat, uat, exp });
const expires = !duration ? 0 : new Date(exp * 1000);

res.cookie(name, value, {expires, ...cookieOptions});
}
}

return (req, res, next) => {
if (!req.hasOwnProperty(COOKIES)) {
req[COOKIES] = cookie.parse(req.get('cookie') || '');
}

if (req.hasOwnProperty(name)) {
return next();
}

let iat;
let exp;

try {

if (req[COOKIES].hasOwnProperty(name)) {
const { protected: header, cleartext } = decrypt(req[COOKIES][name]);
({ iat, exp } = header);
assert(exp > epoch());
req[name] = JSON.parse(cleartext);
}
} finally {
if (!req.hasOwnProperty(name) || !req[name]) {
req[name] = {};
}
}

onHeaders(res, setCookie.bind(undefined, req, res, { iat }));

return next();
};
};
Loading