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 5 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
9 changes: 6 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.
- **`sessionSecret`** - The private key used to encrypt the user identity in a cookie session. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `SESSION_SECRET` variable in your environment.

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,8 @@ 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`.
- **`sessionLength`** - Integer value, in microseconds, indicating application session length. Default is 7 days.
- **`sessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`.

### Authorization Params Key

Expand Down Expand Up @@ -100,12 +103,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
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.
sessionSecret: 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
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',
sessionName: '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
4 changes: 4 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const paramsSchema = Joi.object().keys({
loginPath: Joi.string().optional().default('/login'),
logoutPath: Joi.string().optional().default('/logout'),
legacySameSiteCookie: Joi.boolean().optional().default(true),
sessionName: Joi.string().token().optional().default('identity'),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the actual cookie name for the internal session.

sessionSecret: Joi.alternatives([ Joi.string().min(16), Joi.boolean().valid([false]) ]).required().default(),
sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60 * 1000),

idpLogout: Joi.boolean().optional().default(false)
.when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) })
});
Expand Down
30 changes: 7 additions & 23 deletions lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,20 @@ class RequestContext {
this._next = next;
}

get tokens() {
if (!this._req.session || !this._req.session.openidTokens) {
return undefined;
}
return new TokenSet(this._req.session.openidTokens);
}

set tokens(value) {
this._req.session.openidTokens = value;
}

get isAuthenticated() {
return !!this.user;
}

makeTokenSet(tokenSet) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This lets developers create a TokenSet without importing the library directly.

return new TokenSet(tokenSet);
}

async load() {
if (!this.client) {
this.client = await getClient(this._config);
}
if (this.tokens) {
this.user = await this._config.getUser(this.tokens);
}

this.user = await this._config.getUser(this._req, this._config);
}
}

Expand Down Expand Up @@ -98,15 +90,7 @@ class ResponseContext {
const res = this._res;
const returnURL = params.returnTo || this._config.baseURL;

if (!req.session || !req.openid) {
return res.redirect(returnURL);
}

if (typeof req.session.destroy === 'function') {
req.session.destroy();
} else {
req.session = null;
}
req[this._config.sessionName].destroy();

if (!this._config.idpLogout) {
return res.redirect(returnURL);
Expand Down
10 changes: 8 additions & 2 deletions lib/hooks/getUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
* Default function for mapping a tokenSet to a user.
* This can be used for adjusting or augmenting profile data.
*/
module.exports = function(tokenSet) {
return tokenSet && tokenSet.claims();
module.exports = function(req, config) {

// If there is no sessionSecret, session handing is custom.
if (!config.sessionSecret || !req[config.sessionName] || !req[config.sessionName].claims) {
return null;
}

return req[config.sessionName].claims;
};
13 changes: 3 additions & 10 deletions lib/loadEnvs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,14 @@ const fieldsEnvMap = {
'baseURL': 'BASE_URL',
'clientID': 'CLIENT_ID',
'clientSecret': 'CLIENT_SECRET',
'sessionSecret': 'SESSION_SECRET',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

SESSION_SECRET is the env name for the secret that encrypts the cookie. If this env exists then the config value does not need to be passed to the SDK config.

};

module.exports = function(params) {
Object.keys(fieldsEnvMap).forEach(k => {
if (params[k]) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was treating false as not set

return;
if (typeof params[k] === 'undefined') {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the config key is not present then use the env var for specific keys.

params[k] = process.env[fieldsEnvMap[k]];
}
params[k] = process.env[fieldsEnvMap[k]];
});

if (!params.baseURL &&
Copy link
Contributor Author

Choose a reason for hiding this comment

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

A lot of dancing around to allow a very specific case. Also: sets the URL without TLS.

!process.env.BASE_URL &&
process.env.PORT &&
process.env.NODE_ENV !== 'production') {
params.baseURL = `http://localhost:${process.env.PORT}`;
}
};

Loading