Skip to content
This repository has been archived by the owner on Oct 24, 2024. It is now read-only.

Commit

Permalink
Middleware okta logout (#379)
Browse files Browse the repository at this point in the history
* chore(middleware): Adds appBaseUrl, removes redirect_uri

BREAKING CHANGE:

Configurations now require appBaseUrl.
redirect_uri is deprecated

* feat(middleware): Adds Okta logout

BREAKING CHANGE: adds POST-based /logout and /logout/callback routes
that will override existing matching routes defined by the user (if
any).  See `Upgrading` in the README for more info.
  • Loading branch information
swiftone authored Jan 31, 2019
1 parent a999b95 commit a4b54f7
Show file tree
Hide file tree
Showing 11 changed files with 230 additions and 47 deletions.
79 changes: 66 additions & 13 deletions packages/oidc-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,30 @@ app.use(session({
saveUninitialized: false
}));
app.use(oidc.router);

app.get('/', (req, res) => {
if (req.userContext) {
res.send(`Hello ${req.userContext.userinfo.name}! <a href="logout">Logout</a>`);
res.send(`
Hello ${req.userContext.userinfo.name}!
<form method="POST" action="/logout">
<button type="submit">Logout</button>
</form>
');
} else {
res.send('Please <a href="/login">login</a>');
}
});
app.get('/protected', oidc.ensureAuthenticated(), (req, res) => {
res.send('Top Secret');
});
app.get('/logout', (req, res) => {
req.logout();
res.redirect('/');
});
oidc.on('ready', () => {
app.listen(3000, () => console.log('app started'));
});
oidc.on('error', err => {
// An error occurred while setting up OIDC
// An error occurred while setting up OIDC, during token revokation, or during post-logout handling
});
```

Expand All @@ -111,6 +116,7 @@ oidc.on('error', err => {
* [oidc.on('ready', callback)](#oidconready-callback)
* [oidc.on('error', callback)](#oidconerror-callback)
* [oidc.ensureAuthenticated({ redirectTo?: '/uri' })](#oidcensureauthenticated-redirectto-uri-)
* [oidc.forceLogoutAndRevoke()](#oidcforcelogoutandrevoke)
* [req.isAuthenticated()](#reqisauthenticated)
* [req.logout()](#reqlogout)
* [req.userContext](#requsercontext)
Expand Down Expand Up @@ -143,11 +149,12 @@ Required config:
* **issuer** - The OIDC provider (e.g. `https://{yourOktaDomain}/oauth2/default`)
* **client_id** - An id provided when you create an OIDC app in your Okta Org
* **client_secret** - A secret provided when you create an OIDC app in your Okta Org
* **appBaseUrl** - The base scheme, host, and port (if not 80/443) of your app, not including any path (e.g. http://localhost:3000, not http://localhost:3000/ ) You may specific `loginRedirectUri` to override this setting if you redirect to other apps.
* **appBaseUrl** - The base scheme, host, and port (if not 80/443) of your app, not including any path (e.g. http://localhost:3000, not http://localhost:3000/ )

Optional config:

* **loginRedirectUri** - The URI for your app that Okta will redirect users to after sign in to create the local session. Locally, this is usually `http://localhost:3000/authorization-code/callback`. When deployed, this should be `https://{yourProductionDomain}/authorization-code/callback`. This will default to `{baseUrl}{routes.loginCallback.path}` if `appBaseUrl` is provided, or the (deprecated) `redirect_uri` if appBaseUrl is not provided. Unless your redirect is to a different application, it is recommended to NOT set this parameter and instead set `appBaseUrl` and (if different than the default of `/authorization-code/callback`) `routes.loginCallback.path`.
* **loginRedirectUri** - The URI for your app that Okta will redirect users to after sign in to create the local session. Locally, this is usually `http://localhost:3000/authorization-code/callback`. When deployed, this should be `https://{yourProductionDomain}/authorization-code/callback`. This will default to `{appBaseUrl}{routes.loginCallback.path}` if `appBaseUrl` is provided, or the (deprecated) `redirect_uri` if `appBaseUrl` is not provided. Unless your redirect is to a different application, it is recommended to NOT set this parameter and instead set `appBaseUrl` and (if different than the default of `/authorization-code/callback`) `routes.loginCallback.path`.
* **logoutRedirectUri** - The URI for your app that Okta will redirect users to after sign out to clean up the local session. Locally this is usually `http://localhost:3000/logout/callback`. When deployed, this should be `https://{yourProductionDomain}/logout/callback`. This will default to `{appBaseUrl}{routes.logoutCallback.path}` if `appBaseUrl` is provided. Unless your redirect is to a different application, it is recommended to NOT set this parameter and instead set `appBaseUrl` and (if different than the default of `/logout/callback`) `routes.logoutCallback.path`.
* **response_type** - Defaults to `code`
* **scope** - Defaults to `openid`, which will only return the `sub` claim. To obtain more information about the user, use `openid profile`. For a list of scopes and claims, please see [Scope-dependent claims](https://developer.okta.com/standards/OIDC/index.html#scope-dependent-claims-not-always-returned) for more information.
* **routes** - Allows customization of the generated routes. See [Customizing Routes](#customizing-routes) for details.
Expand All @@ -168,11 +175,12 @@ const oidc = new ExpressOIDC({ /* options */ });
app.use(oidc.router);
```

It's required in order for `ensureAuthenticated`, and `isAuthenticated` to work and adds the following routes:
The router is required in order for `ensureAuthenticated`, and `isAuthenticated`, and `forceLogoutAndRevoke` to work and adds the following routes:

* `/login` - redirects to the Okta sign-in page by default
* `/authorization-code/callback` - processes the OIDC response, then attaches userinfo to the session

* `/logout` - revokes any known Okta access/refresh tokens, then redirects to the Okta logout endpoint which then redirects back to a callback url for logout specified in your Okta settings
* `/logout/callback` - the default callback url that Okta will redirect back to after the session at Okta is ended
The paths for these generated routes can be customized using the `routes` config, see [Customizing Routes](#customizing-routes) for details.

#### oidc.on('ready', callback)
Expand Down Expand Up @@ -210,6 +218,16 @@ app.get('/protected', oidc.ensureAuthenticated(), (req, res) => {

The `redirectTo` option can be used to redirect the user to a specific URI on your site after a successful authentication callback.

#### oidc.forceLogoutAndRevoke()

Use this to define a route that will force a logout of the user from Okta and the local session. Because logout involves redirecting to Okta and then to the logout callback URI, the body of this route will never directly execute. It is recommended to not perform logout on GET queries as it is prone to attacks and/or prefetching misadventures.

```javascript
app.post('/forces-logout', oidc.forceLogoutAndRevoke(), (req, res) => {
// Nothing here will execute, after the redirects the user will end up wherever the `routes.logoutCallback.afterCallback` specifies (default `/`)
});
```

#### req.isAuthenticated()

This allows you to determine if a user is authenticated.
Expand All @@ -226,10 +244,10 @@ app.get('/', (req, res) => {

#### req.logout()

This allows you to end the session.
This allows you to end the local session while leaving the user logged in to Okta, meaning that if they attempt to reauthenticate to your app they will not be prompted to re-enter their credentials unless their Okta session has expired. To end the Okta session, POST to the autogenerated `/logout` route or send the user to a route you defined using the `oidc.forceLogoutAndRevoke()` method above.

```javascript
app.get('/logout', (req, res) => {
app.get('/local-logout', (req, res) => {
req.logout();
res.redirect('/');
});
Expand Down Expand Up @@ -281,6 +299,13 @@ const oidc = new ExpressOIDC({
// Perform custom logic before final redirect, then call next()
},
afterCallback '/home'
},
logout: {
path: '/different/logout'
},
logoutCallback: {
path: '/different/logout-callback',
afterCallback: '/thank-you'
}
}
});
Expand All @@ -291,6 +316,9 @@ const oidc = new ExpressOIDC({
* **`loginCallback.handler`** - A function that is called after a successful authentication callback, but before the final redirect within your application. Useful for requirements such as conditional post-authentication redirects, or sending data to logging systems.
* **`loginCallback.path`** - The URI that this library will host the login callback handler on. Defaults to `/authorization-code/callback`. Must match a value from the Login Redirect Uri list from the Okta console for this application.
* **`login.path`** - The URI that redirects the user to the Okta authorize endpoint. Defaults to `/login`.
* **`logout.path`** - The URI that redirects the user to the Okta logout endpoint. Defaults to `/logout`.
* **`logoutCallback.afterCallback`** - Where the user is redirected to after a successful logout callback, if no `redirectTo` value was specified by `oidc.forceLogoutAndRevoke()`. Defaults to `/`.
* **`logoutCallback.path`** - The URI that this library will host the logout callback handler on. Defaults to `/logout/callback`. Must match a value from the Logout Redirect Uri list from the Okta console for this application.

#### Using a Custom Login Page

Expand Down Expand Up @@ -380,7 +408,13 @@ Once you have done that you can read the documentation on the [request][] librar
#### from 1.x to 2.x
The 2.x improves support for default options without removing flexibility
The 2.x improves support for default options without removing flexibility and adds logout functionality that includes Okta logout and token revocation, not just local session destruction.
Specify the `appBaseUrl` property in your config - this is the base scheme + domain + port for your application that will be used for generating the URIs validated against the Okta settings for your application.
Remove the `redirect_uri` property in your config.
* If you are using the Okta default value (appBaseUrl + /authorization-code/callback) it will be given a route by default, no additional configuration required.
* If you are NOT using the Okta default value, but are using a route on the same server indicated by your appBaseUrl, you should define your login callback path in your routes.loginCallback.path config (see [the API reference](#expressoidc-api)).
Specify the `appBaseUrl` property in your config - this is the base scheme + domain + port for your application that will be used for generating the URIs validated against the Okta settings for your application.
Expand All @@ -392,6 +426,25 @@ Any customization previously done to `routes.callback` should now be done to `ro
Any value previously set for `routes.callback.defaultRedirect` should now be done to `routes.loginCallback.afterCallback`.
##### Straightforward Okta logout for your app
Configure a logout redirect uri for your application in the Okta admin console for your application, if one is not already defined
* If you do not, logouts will not return to your application but will end on the Okta site
* Okta recommends `{appBaseUrl}/logout/callback`. Be sure to fully specify the uri for your application
* If you chose a different logout redirect uri, specify the path for the local route to create in your routes.logoutCallback.path value (see [the API reference](#expressoidc-api)).
By default the middleware will create a `/logout` (POST only) route. You should remove any local `/logout` route you have added - if it only destroyed the local session (per the example from the 1.x version of this library) you can simply remove it. If it did additional post-logout logic, you can change the path of the route and list that path in the route.logoutCallback.afterCallback option (see [the API reference](#expressoidc-api)).
##### Local logout
If you had previously implemented a '/logout' route that called `req.logout()` (performing a local logout for your app) you should remove that route and use the new automatically added `/logout` route. If you used that route using direct links or GET requests, those will have to become POST requests. You can create a GET route for /logout, but that as a GET request is open for abuse and/or pre-fetching complications it is not recommended.
If you desire to have a route that performs a local logout while leaving the user logged in to Okta, you can create any route you wish (that does not conflict with automatically created routes) and call `req.logout()` to destroy your local session without altering the status of the user's browser session at Okta.
#### Okta with additional apps
If you had the `redirect_uri` pointing to a different application than this one, replace `redirect_uri` with `loginRedirectUri`, and consider if you need to set `logoutRedirectUri`.
## Contributing
We're happy to accept contributions and PRs! Please see the [contribution guide](https://github.com/okta/okta-oidc-js/blob/master/CONTRIBUTING.md) to understand how to structure a contribution.
1 change: 1 addition & 0 deletions packages/oidc-middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"express": "^4.16.3",
"lodash": "^4.17.5",
"negotiator": "^0.6.1",
"node-fetch": "^2.3.0",
"openid-client": "2.1.0",
"passport": "^0.3.2",
"uuid": "^3.1.0"
Expand Down
26 changes: 24 additions & 2 deletions packages/oidc-middleware/src/ExpressOIDC.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const EventEmitter = require('events').EventEmitter;
const merge = require('lodash/merge');
const oidcUtil = require('./oidcUtil');
const connectUtil = require('./connectUtil');
const logout = require('./logout');

const {
assertIssuer,
assertClientId,
Expand All @@ -37,7 +39,8 @@ module.exports = class ExpressOIDC extends EventEmitter {
* @param {string} options.issuer The OpenId Connect issuer
* @param {string} options.client_id This app's OpenId Connect client id
* @param {string} options.client_secret This app's OpenId Connect client secret
* @param {string} options.loginRedirectUri The location of the login authorization callback
* @param {string} options.loginRedirectUri The location of the login authorization callback if not redirecting to this app
* @param {string} options.logoutRedirectUri The location of the logout callback if not redirecting to this app
* @param {string} [options.scope=openid] The scopes that will determine the claims on the tokens
* @param {string} [options.response_type=code] The OpenId Connect response type
* @param {number} [options.maxClockSkew=120] The maximum discrepancy allowed between server clocks in seconds
Expand All @@ -59,6 +62,7 @@ module.exports = class ExpressOIDC extends EventEmitter {
client_secret,
appBaseUrl,
loginRedirectUri,
logoutRedirectUri,
sessionKey
} = options;

Expand All @@ -85,14 +89,22 @@ module.exports = class ExpressOIDC extends EventEmitter {
loginCallback: {
path: '/authorization-code/callback',
afterCallback: '/'
},
logout: {
path: '/logout'
},
logoutCallback: {
path: '/logout/callback',
afterCallback: '/'
}
},
sessionKey: sessionKey || `oidc:${issuer}`,
maxClockSkew: 120
}, options)
}, options);

// Build redirect uri unless explicitly set
options.loginRedirectUri = loginRedirectUri || `${appBaseUrl}${options.routes.loginCallback.path}`;
options.logoutRedirectUri = logoutRedirectUri || `${appBaseUrl}${options.routes.logoutCallback.path}`;

// Validate the redirect_uri param
assertRedirectUri(options.loginRedirectUri);
Expand Down Expand Up @@ -122,6 +134,16 @@ module.exports = class ExpressOIDC extends EventEmitter {
*/
this.ensureAuthenticated = oidcUtil.ensureAuthenticated.bind(null, context);

/**
* Perform a logout at the Okta side and revoke the id/access tokens
* Will 200 even if user is not logged in
*
* @instance
* @function
* @memberof ExpressOIDC
*/
this.forceLogoutAndRevoke = logout.forceLogoutAndRevoke.bind(null, context);

// create client
oidcUtil.createClient(context)
.then(client => {
Expand Down
18 changes: 18 additions & 0 deletions packages/oidc-middleware/src/connectUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { Router } = require('express');
const querystring = require('querystring');
const uuid = require('uuid');
const bodyParser = require('body-parser');
const logout = require('./logout');

const connectUtil = module.exports;

Expand All @@ -28,9 +29,13 @@ connectUtil.createOIDCRouter = context => {

const loginPath = routes.login.path;
const loginCallbackPath = routes.loginCallback.path;
const logoutPath = routes.logout.path;
const logoutCallbackPath = routes.logoutCallback.path;

oidcRouter.use(loginPath, bodyParser.urlencoded({ extended: false}), connectUtil.createLoginHandler(context));
oidcRouter.use(loginCallbackPath, connectUtil.createLoginCallbackHandler(context));
oidcRouter.post(logoutPath, connectUtil.createLogoutHandler(context));
oidcRouter.use(logoutCallbackPath, connectUtil.createLogoutCallbackHandler(context));

oidcRouter.use((err, req, res, next) => {
// Cast all errors from the passport strategy as 401 (rather than 500, which would happen if we just call through to next())
Expand Down Expand Up @@ -106,3 +111,16 @@ connectUtil.createLoginCallbackHandler = context => {
passport.authenticate('oidc')(req, res, nextHandler);
}
};

connectUtil.createLogoutHandler = context => logout.forceLogoutAndRevoke(context);

connectUtil.createLogoutCallbackHandler = context => {
return (req, res) => {
if ( req.session[context.options.sessionKey].state !== req.query.state ) {
context.emitter.emit('error', { type: 'logoutError', message: `'state' parameter did not match value in session` });
} else {
req.logout();
res.redirect(context.options.routes.logoutCallback.afterCallback);
};
};
};
74 changes: 74 additions & 0 deletions packages/oidc-middleware/src/logout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*!
* Copyright (c) 2019-Present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/
const fetch = require('node-fetch');
const querystring = require('querystring');
const uuid = require('uuid');

const logout = module.exports;

const makeErrorHandler = emitter => err => {
if (err.type) {
emitter.emit('error', `${err.type} - ${err.text}`);
} else {
emitter.emit('error', err);
}
};

const makeAuthorizationHeader = ({ client_id, client_secret }) =>
'Basic ' + Buffer.from(`${client_id}:${client_secret}`).toString('base64');

const makeTokenRevoker = ({ issuer, client_id, client_secret, errorHandler }) => {
const revokeEndpoint = `${issuer}/v1/revoke`;

return ({ token_hint, token }) => {
return fetch(revokeEndpoint, {
method: 'POST',
headers: {
'accepts': 'application/json',
'content-type': 'application/x-www-form-urlencoded',
'authorization': makeAuthorizationHeader({ client_id, client_secret }),
},
body: querystring.stringify({token, token_type_hint: token_hint}),
})
.then( r => r.ok ? r : r.text().then( e => Promise.reject({type: 'revokeError', message: e}) ))
.catch( errorHandler ); // catch and emit - this promise chain can never fail
};
};


logout.forceLogoutAndRevoke = context => {
const emitter = context.emitter;
const { issuer, client_id, client_secret } = context.options;
const REVOKABLE_TOKENS = ['refresh_token', 'access_token'];

const revokeToken = makeTokenRevoker({ issuer, client_id, client_secret, errorHandler: makeErrorHandler(emitter) });
return async (req, res, next) => {
const tokens = req.userContext.tokens;
const revokeIfExists = token_hint => tokens[token_hint] ? revokeToken({token_hint, token: tokens[token_hint]}) : null;
const revokes = REVOKABLE_TOKENS.map( revokeIfExists );
// attempt all revokes before logout
await Promise.all(revokes); // these capture (emit) all rejections, no wrapping catch needed, no early fail of .all()

const state = uuid.v4();
const params = {
state,
id_token_hint: tokens.id_token,
post_logout_redirect_uri: context.options.logoutRedirectUri,
};
req.session[context.options.sessionKey] = { state };

const endOktaSessionEndpoint = `${context.options.issuer}/v1/logout?${querystring.stringify(params)}`;
return res.redirect(endOktaSessionEndpoint);
};
};


Loading

0 comments on commit a4b54f7

Please sign in to comment.