From d2d7aec8427875b7273017e7d2882225e67d1489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Noco=C5=84?= Date: Thu, 20 Sep 2018 18:09:50 +0200 Subject: [PATCH 1/3] Implement the Back-Channel Logout mechanism --- .gitignore | 3 ++ README.md | 2 + package-lock.json | 35 +++++++++++-- package.json | 3 +- src/index.js | 127 +++++++++++++++++++++++++++++++--------------- 5 files changed, 124 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 53e460c..317941c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ typings/ .idea/ *.iml + +# Output of 'npm run build' +dist diff --git a/README.md b/README.md index 05b167f..a38c443 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,14 @@ Parameter | Description | Default `minTimeBetweenJwksRequests` | Amount of time, in seconds, specifying minimum interval between two requests to Keycloak to retrieve new public keys. | `10` `loginUrl` | An URL of the endpoint responsible for obtaining OAuth2.0's Authorization Code grant. It is exposed only if `bearerOnly` is set to false. | `/sso/login` `logoutUrl` | An URL the endpoint responsible for handling logout procedure. It is exposed only if `bearerOnly` is set to false. | `/sso/logout` +`backChannelLogoutUrl` | An URL of the endpoint responsible for handling the [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) procedure. It is exposed only if `bearerOnly` is set to false. | `/k_logout` `principalUrl` | An URL of the endpoint exposing resource owner's data (such as its name, ID token, access token etc.). Use `null` in order not to expose this endpoint at all. | `/api/principal` `principalConversion` | A function which alters principal representation exposed by `principalUrl` endpoint before it's sent in a response. Define this function if you don't want for example an access token to be exposed. | `undefined` (no conversion) `principalNameAttribute` | An access/ID token attribute which will be used as the principal name (user name). It will fallback to *sub* token attribute in case the *principalNameAttribute* is not present. Possible values are *sub*, *preferred_username*, *email*, *name*. | `name` `corsOrigin` | CORS for the `loginUrl` and `logoutUrl` endpoints. In production, only Keycloak server's FQDN should be defined here. | `['*']` `shouldRedirectUnauthenticated` | A function used for not authenticated users. It takes a `request` as a parameter and should return: - `false` - if the endpoint should reply with an HTTP 401 right away. - `true` - if the user should be redirected to the Keycloak login page. By default, `401` will be returned when `bearerOnly` is set to `true`, route auth mode is set to `optional` or `try` or if we're accessing `/api/*` route. | `basePath` | A base path to use if app is running behind a reverse proxy. This path will be inserted in redirect URIs. It could be useful when proxy changes the base path. | `undefined` +`cacheName` | The name of cache where sessions are stored. This library use the [yar](https://github.com/hapijs/yar) abstraction for session management, so the value of this parameter must be the same as the name of cache storage. | `_default` ## Examples diff --git a/package-lock.json b/package-lock.json index 66d3513..ae7fdf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "keycloak-hapi", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -68,6 +68,15 @@ "dev": true, "optional": true }, + "axios": { + "version": "0.18.0", + "resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz", + "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "requires": { + "follow-redirects": "^1.3.0", + "is-buffer": "^1.1.5" + } + }, "babel-cli": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-cli/-/babel-cli-6.26.0.tgz", @@ -962,6 +971,24 @@ "repeat-string": "^1.5.2" } }, + "follow-redirects": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.8.tgz", + "integrity": "sha512-sy1mXPmv7kLAMKW/8XofG7o9T+6gAjzdZK4AJF6ryqQYUa/hnzgiypoeUecZ53x7XiqKNEpNqLtS97MshW2nxg==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -1646,8 +1673,7 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" }, "is-dotfile": { "version": "1.0.3", @@ -1862,8 +1888,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "nan": { "version": "2.10.0", diff --git a/package.json b/package.json index 6fe6a5a..1a1b3a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keycloak-hapi", - "version": "1.0.3", + "version": "1.1.0", "description": "Integration of Keycloak Authorization Server with HapiJS", "main": "dist/index.js", "scripts": { @@ -27,6 +27,7 @@ }, "homepage": "https://github.com/novomatic-tech/keycloak-hapi#readme", "dependencies": { + "axios": "^0.18.0", "babel-runtime": "^6.26.0", "boom": "^7.2.0", "keycloak-connect": "^4.0.0", diff --git a/src/index.js b/src/index.js index 80cb761..0b69548 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ const UUID = require('keycloak-connect/uuid'); const Boom = require('boom'); const _ = require('lodash'); const pkg = require('../package.json'); +const axios = require('axios'); const urljoin = require('url-join'); const getProtocol = (request) => request.headers['x-forwarded-proto'] || request.server.info.protocol; @@ -13,7 +14,7 @@ const getHost = (request) => request.headers['x-forwarded-host'] || request.info class SessionGrantStore { constructor(options = null) { - this.options = Object.assign({ + this.options = Object.assign({ key: 'kc_auth_grant' }, options); this.name = 'session'; @@ -141,7 +142,7 @@ const createPrincipalResource = (principal) => { if (!principal) { return principal; } - const { name, scope, accessToken, idToken } = principal; + const {name, scope, accessToken, idToken} = principal; const formattedPrincipal = { name, scope, @@ -185,11 +186,13 @@ class KeycloakAdapter { this.config = Object.assign({ loginUrl: '/sso/login', logoutUrl: '/sso/logout', + backChannelLogoutUrl: '/k_logout', principalUrl: '/api/principal', corsOrigin: ['*'], principalConversion: defaultPrincipalConversion, principalNameAttribute: 'name', shouldRedirectUnauthenticated: defaultShouldRedirectUnauthenticated(config), + cacheName: '_default' }, config); if (!this.config.secret) { this.config.secret = this.config.clientSecret; @@ -213,13 +216,13 @@ class KeycloakAdapter { return stores; } - obtainGrantFromCode(code, redirectUri) { - const req = { - session: { auth_redirect_uri: redirectUri } + obtainGrantFromCode(code, redirectUri, sessionId) { + const req = { + session: {auth_redirect_uri: redirectUri} }; - return this.grantManager.obtainFromCode(req, code); + return this.grantManager.obtainFromCode(req, code, sessionId); } - + getLoginUrl(redirectUrl, stateUuid = null) { return this.keycloakConfig.realmUrl + '/protocol/openid-connect/auth' + @@ -230,10 +233,12 @@ class KeycloakAdapter { '&response_type=code'; } - getLogoutUrl(redirectUrl = null) { - return this.keycloakConfig.realmUrl + - '/protocol/openid-connect/logout' + - '?redirect_uri=' + encodeURIComponent(redirectUrl); + getLogoutUrl({redirectUrl, idTokenHint}) { + return urljoin( + this.keycloakConfig.realmUrl, + '/protocol/openid-connect/logout', + redirectUrl ? '?redirect_uri=' + encodeURIComponent(redirectUrl) : '', + idTokenHint ? '?id_token_hint=' + encodeURIComponent(idTokenHint) : ''); } getChangePasswordUrl() { @@ -253,7 +258,7 @@ class KeycloakAdapter { getLoginRedirectUrl(request) { return urljoin(this.getBaseUrl(request), this.config.loginUrl, '?auth_callback=1'); } - + getAssignedRoles(accessToken) { const appRoles = _.get(accessToken, `content.resource_access['${this.keycloakConfig.clientId}'].roles`, []); const realmRoles = _.get(accessToken, 'content.realm_access.roles', []); @@ -291,7 +296,7 @@ class KeycloakAdapter { grant = await this.grantManager.validateGrant(grant); } return this.getPrincipal(grant); - } catch(err) { + } catch (err) { log(['warn', 'keycloak'], `Authorization has failed - Received grant is invalid: ${err}.`); grantStore.clearGrant(request); return null; @@ -307,19 +312,19 @@ class KeycloakAdapter { return (server, options) => { return { authenticate: async (request, reply) => { - const credentials = await keycloak.authenticate(request, reply); - server.log(['debug', 'keycloak'], `Authentication request. URL: ${request.raw.req.url}, user: ${credentials ? credentials.name : '[Anonymous]'}`); - if (credentials) { - return keycloak.answer(reply).authenticated({ credentials }); - } else { - if (keycloak.config.shouldRedirectUnauthenticated(request)) { - const loginUrl = keycloak.getLoginUrl(keycloak.getLoginRedirectUrl(request)); - server.log(['debug', 'keycloak'], `User is not authenticated - redirecting to ${loginUrl}`); - return reply.response().takeover().redirect(loginUrl); - } else { - return keycloak.answer(reply).representation(Boom.unauthorized('The resource owner is not authenticated.', 'bearer', { realm: keycloak.config.realm })); - } - } + const credentials = await keycloak.authenticate(request, reply); + server.log(['debug', 'keycloak'], `Authentication request. URL: ${request.raw.req.url}, user: ${credentials ? credentials.name : '[Anonymous]'}`); + if (credentials) { + return keycloak.answer(reply).authenticated({credentials}); + } else { + if (keycloak.config.shouldRedirectUnauthenticated(request)) { + const loginUrl = keycloak.getLoginUrl(keycloak.getLoginRedirectUrl(request)); + server.log(['debug', 'keycloak'], `User is not authenticated - redirecting to ${loginUrl}`); + return reply.response().takeover().redirect(loginUrl); + } else { + return keycloak.answer(reply).representation(Boom.unauthorized('The resource owner is not authenticated.', 'bearer', {realm: keycloak.config.realm})); + } + } } }; }; @@ -350,9 +355,11 @@ class KeycloakAdapter { register() { this.server.auth.scheme('keycloak', this.getAuthScheme.bind(this)()); + registerDropSessionMethod(this); if (!this.config.bearerOnly) { registerLoginRoute(this); registerLogoutRoute(this); + registerBackChannelLogoutRoute(this); } if (this.config.principalUrl) { registerPrincipalRoute(this); @@ -405,11 +412,11 @@ const registerLoginRoute = (keycloak) => { } try { log(['debug', 'keycloak'], `Processing authorization code`); - const grant = await keycloak.obtainGrantFromCode(request.query.code, redirectUrl); + const grant = await keycloak.obtainGrantFromCode(request.query.code, redirectUrl, request.yar.id); grantStore.saveGrant(request, grant); log(['debug', 'keycloak'], `Access token has been successfully obtained from the authorization code:\n${grant}`); return reply.redirect(keycloak.getBaseUrl(request)); - } catch(err) { + } catch (err) { const errorMessage = `Unable to authenticate - could not obtain grant code. ${err}`; log(['error', 'keycloak'], errorMessage); return keycloak.answer(reply).representation(Boom.forbidden(errorMessage)); @@ -425,27 +432,67 @@ const registerLoginRoute = (keycloak) => { }); }; +const registerDropSessionMethod = (keycloak) => { + keycloak.server.method('dropSession', (sessionId) => { + const cache = keycloak.server._core.caches.get(keycloak.config.cacheName); + if (cache) { + return cache.client.drop({id: sessionId, segment: '!yar'}) + } + throw new Error(`Cannot find the "${keycloak.config.cacheName}" cache for drop procedure.`); + }) +}; + +const registerBackChannelLogoutRoute = (keycloak) => { + keycloak.server.route({ + path: keycloak.config.backChannelLogoutUrl, + method: 'POST', + handler: async (request, reply) => { + keycloak.server.log(['debug', 'keycloak'], 'Back-channel logout'); + const logoutToken = new Token(request.payload); + const sessionIds = logoutToken.content.adapterSessionIds || []; + try { + await Promise.all(sessionIds.map(keycloak.server.methods.dropSession)); + } catch (ex) { + keycloak.server.log(['warn', 'keycloak'], `An error occurred during dropping sessions. Error: ${ex}`); + } + return keycloak.answer(reply).representation('Successfully dropped all user\'s sessions.'); + }, + config: { + auth: false + } + }); +}; + const registerLogoutRoute = (keycloak) => { keycloak.server.route({ path: keycloak.config.logoutUrl, method: 'GET', - handler(request, reply) { + handler: async (request, reply) => { keycloak.server.log(['debug', 'keycloak'], 'Signing out'); const grantStore = keycloak.getGrantStoreByName('session'); + const grant = grantStore.getGrant(request); + const baseUrl = keycloak.getBaseUrl(request); + if (!grant) { + return reply.redirect(baseUrl); + } grantStore.clearGrant(request); - const redirectUrl = keycloak.getBaseUrl(request); - const logoutUrl = keycloak.getLogoutUrl(redirectUrl); - return reply.redirect(logoutUrl); + const logoutUrl = keycloak.getLogoutUrl({idTokenHint: grant.id_token.token}); + try { + await axios.get(logoutUrl); + keycloak.server.log(['debug', 'keycloak'], 'Successfully signed out from the authentication server.'); + } catch (ex) { + keycloak.server.log(['warn', 'keycloak'], `An error occurred during signing out from the authentication server. Error: ${ex}`); + } + + return reply.redirect(baseUrl); }, config: { - auth: false, - cors: { - origin: keycloak.config.corsOrigin - } + auth: false } }); }; + /* This is a plugin registration backward-compatible with Hapijs v14+ */ const register = (server, options, next) => { const adapter = new KeycloakAdapter(server, options); @@ -454,9 +501,9 @@ const register = (server, options, next) => { next(); } }; -register.attributes = { pkg }; +register.attributes = {pkg}; module.exports = { - register, - pkg, - KeycloakAdapter + register, + pkg, + KeycloakAdapter }; From b8b0d2e110dd1d1da871a811c3a93ef26b0f34bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Noco=C5=84?= Date: Thu, 27 Dec 2018 16:21:35 +0100 Subject: [PATCH 2/3] Improve back-channel logout security, revert logout endpoint. --- README.md | 7 ++- changelog.md | 4 ++ package-lock.json | 33 ++----------- package.json | 1 - src/index.js | 121 ++++++++++++++++++++++++++++++++-------------- 5 files changed, 98 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index a38c443..9d44457 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,18 @@ Parameter | Description | Default `minTimeBetweenJwksRequests` | Amount of time, in seconds, specifying minimum interval between two requests to Keycloak to retrieve new public keys. | `10` `loginUrl` | An URL of the endpoint responsible for obtaining OAuth2.0's Authorization Code grant. It is exposed only if `bearerOnly` is set to false. | `/sso/login` `logoutUrl` | An URL the endpoint responsible for handling logout procedure. It is exposed only if `bearerOnly` is set to false. | `/sso/logout` -`backChannelLogoutUrl` | An URL of the endpoint responsible for handling the [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) procedure. It is exposed only if `bearerOnly` is set to false. | `/k_logout` `principalUrl` | An URL of the endpoint exposing resource owner's data (such as its name, ID token, access token etc.). Use `null` in order not to expose this endpoint at all. | `/api/principal` `principalConversion` | A function which alters principal representation exposed by `principalUrl` endpoint before it's sent in a response. Define this function if you don't want for example an access token to be exposed. | `undefined` (no conversion) `principalNameAttribute` | An access/ID token attribute which will be used as the principal name (user name). It will fallback to *sub* token attribute in case the *principalNameAttribute* is not present. Possible values are *sub*, *preferred_username*, *email*, *name*. | `name` `corsOrigin` | CORS for the `loginUrl` and `logoutUrl` endpoints. In production, only Keycloak server's FQDN should be defined here. | `['*']` `shouldRedirectUnauthenticated` | A function used for not authenticated users. It takes a `request` as a parameter and should return: - `false` - if the endpoint should reply with an HTTP 401 right away. - `true` - if the user should be redirected to the Keycloak login page. By default, `401` will be returned when `bearerOnly` is set to `true`, route auth mode is set to `optional` or `try` or if we're accessing `/api/*` route. | `basePath` | A base path to use if app is running behind a reverse proxy. This path will be inserted in redirect URIs. It could be useful when proxy changes the base path. | `undefined` -`cacheName` | The name of cache where sessions are stored. This library use the [yar](https://github.com/hapijs/yar) abstraction for session management, so the value of this parameter must be the same as the name of cache storage. | `_default` ## Examples See https://github.com/novomatic-tech/keycloak-examples/tree/master/app-web-nodejs + +## Yar compatibility + +This package requires the [yar](https://www.npmjs.com/package/yar) library at least in version ``9.1.0``. +To get compatibility with version ``8``, use [yar8](https://www.npmjs.com/package/yar8). \ No newline at end of file diff --git a/changelog.md b/changelog.md index fad61f9..951527f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,7 @@ +## 1.1.0 + +* Add support for [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) procedure + ## 1.0.3 * Add possibility to specify a base path of reverse proxy diff --git a/package-lock.json b/package-lock.json index ae7fdf6..340a2b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,15 +68,6 @@ "dev": true, "optional": true }, - "axios": { - "version": "0.18.0", - "resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", - "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" - } - }, "babel-cli": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-cli/-/babel-cli-6.26.0.tgz", @@ -971,24 +962,6 @@ "repeat-string": "^1.5.2" } }, - "follow-redirects": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.8.tgz", - "integrity": "sha512-sy1mXPmv7kLAMKW/8XofG7o9T+6gAjzdZK4AJF6ryqQYUa/hnzgiypoeUecZ53x7XiqKNEpNqLtS97MshW2nxg==", - "requires": { - "debug": "=3.1.0" - }, - "dependencies": { - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - } - } - } - }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -1673,7 +1646,8 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-dotfile": { "version": "1.0.3", @@ -1888,7 +1862,8 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true }, "nan": { "version": "2.10.0", diff --git a/package.json b/package.json index 1a1b3a1..0c3220c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ }, "homepage": "https://github.com/novomatic-tech/keycloak-hapi#readme", "dependencies": { - "axios": "^0.18.0", "babel-runtime": "^6.26.0", "boom": "^7.2.0", "keycloak-connect": "^4.0.0", diff --git a/src/index.js b/src/index.js index 0b69548..77f83e3 100644 --- a/src/index.js +++ b/src/index.js @@ -6,12 +6,68 @@ const UUID = require('keycloak-connect/uuid'); const Boom = require('boom'); const _ = require('lodash'); const pkg = require('../package.json'); -const axios = require('axios'); +const crypto = require('crypto'); const urljoin = require('url-join'); const getProtocol = (request) => request.headers['x-forwarded-proto'] || request.server.info.protocol; const getHost = (request) => request.headers['x-forwarded-host'] || request.info.host; +class ActionTokenVerifier { + + constructor(grantManager) { + this.grantManager = grantManager; + } + + async verify(token, {action, resource}) { + + const tokenRules = [ + { + verify: () => token, + error: new Error('Invalid token (missing)') + }, + { + verify: () => token.content.expiration * 1000 > Date.now(), + error: new Error('Invalid token (expired)') + }, + { + verify: () => token.signed, + error: new Error('Invalid token (not signed)') + }, + { + verify: () => token.content.action === action, + error: new Error('Invalid token (wrong action)') + }, + { + verify: () => token.content.resource === resource, + error: new Error('Invalid token (wrong resource)') + }, + { + verify: async () => { + const verify = crypto.createVerify('RSA-SHA256'); + if (this.grantManager.publicKey) { + verify.update(token.signed); + return verify.verify(this.grantManager.publicKey, token.signature, 'base64'); + } else { + const key = await this.grantManager.rotation.getJWK(token.header.kid); + verify.update(token.signed); + return verify.verify(key, token.signature) + } + }, + error: new Error('Invalid token (signature)') + } + ]; + + for (const rule of tokenRules) { + if (!await rule.verify()) { + throw rule.error; + } + } + + return token; + } + +} + class SessionGrantStore { constructor(options = null) { this.options = Object.assign({ @@ -186,7 +242,6 @@ class KeycloakAdapter { this.config = Object.assign({ loginUrl: '/sso/login', logoutUrl: '/sso/logout', - backChannelLogoutUrl: '/k_logout', principalUrl: '/api/principal', corsOrigin: ['*'], principalConversion: defaultPrincipalConversion, @@ -199,6 +254,7 @@ class KeycloakAdapter { } this.keycloakConfig = new KeycloakConfig(this.config); this.grantManager = new GrantManager(this.keycloakConfig); + this.actionTokenVerifier = new ActionTokenVerifier(this.grantManager); this.grantSerializer = new GrantSerializer(this.config.clientId); this.grantStores = this.createGrantStores(this.config.bearerOnly); this.replyStrategy = server.version < '17' @@ -216,11 +272,11 @@ class KeycloakAdapter { return stores; } - obtainGrantFromCode(code, redirectUri, sessionId) { + obtainGrantFromCode(code, redirectUri, sessionId, sessionHost) { const req = { session: {auth_redirect_uri: redirectUri} }; - return this.grantManager.obtainFromCode(req, code, sessionId); + return this.grantManager.obtainFromCode(req, code, sessionId, sessionHost); } getLoginUrl(redirectUrl, stateUuid = null) { @@ -355,7 +411,6 @@ class KeycloakAdapter { register() { this.server.auth.scheme('keycloak', this.getAuthScheme.bind(this)()); - registerDropSessionMethod(this); if (!this.config.bearerOnly) { registerLoginRoute(this); registerLogoutRoute(this); @@ -412,7 +467,7 @@ const registerLoginRoute = (keycloak) => { } try { log(['debug', 'keycloak'], `Processing authorization code`); - const grant = await keycloak.obtainGrantFromCode(request.query.code, redirectUrl, request.yar.id); + const grant = await keycloak.obtainGrantFromCode(request.query.code, redirectUrl, request.yar.id, keycloak.getBaseUrl(request)); grantStore.saveGrant(request, grant); log(['debug', 'keycloak'], `Access token has been successfully obtained from the authorization code:\n${grant}`); return reply.redirect(keycloak.getBaseUrl(request)); @@ -432,29 +487,32 @@ const registerLoginRoute = (keycloak) => { }); }; -const registerDropSessionMethod = (keycloak) => { - keycloak.server.method('dropSession', (sessionId) => { - const cache = keycloak.server._core.caches.get(keycloak.config.cacheName); - if (cache) { - return cache.client.drop({id: sessionId, segment: '!yar'}) - } - throw new Error(`Cannot find the "${keycloak.config.cacheName}" cache for drop procedure.`); - }) -}; - const registerBackChannelLogoutRoute = (keycloak) => { keycloak.server.route({ - path: keycloak.config.backChannelLogoutUrl, + path: '/k_logout', method: 'POST', handler: async (request, reply) => { keycloak.server.log(['debug', 'keycloak'], 'Back-channel logout'); + const logoutToken = new Token(request.payload); + + try { + await keycloak.actionTokenVerifier.verify(logoutToken, {action: 'LOGOUT', resource: keycloak.config.clientId}); + } catch (ex) { + const message = `Invalid token has been provided. ${ex}`; + keycloak.server.log(['warn', 'keycloak'], message); + return keycloak.answer(reply).representation(Boom.badRequest(message)); + } + const sessionIds = logoutToken.content.adapterSessionIds || []; try { - await Promise.all(sessionIds.map(keycloak.server.methods.dropSession)); + await Promise.all(sessionIds.map(sessionId => keycloak.server.yar.revoke(sessionId))); } catch (ex) { - keycloak.server.log(['warn', 'keycloak'], `An error occurred during dropping sessions. Error: ${ex}`); + const message = `An error occurred during dropping sessions. ${ex}`; + keycloak.server.log(['warn', 'keycloak'], message); + return keycloak.answer(reply).representation(Boom.notImplemented(message)); } + return keycloak.answer(reply).representation('Successfully dropped all user\'s sessions.'); }, config: { @@ -467,32 +525,23 @@ const registerLogoutRoute = (keycloak) => { keycloak.server.route({ path: keycloak.config.logoutUrl, method: 'GET', - handler: async (request, reply) => { + handler(request, reply) { keycloak.server.log(['debug', 'keycloak'], 'Signing out'); const grantStore = keycloak.getGrantStoreByName('session'); - const grant = grantStore.getGrant(request); - const baseUrl = keycloak.getBaseUrl(request); - if (!grant) { - return reply.redirect(baseUrl); - } grantStore.clearGrant(request); - const logoutUrl = keycloak.getLogoutUrl({idTokenHint: grant.id_token.token}); - try { - await axios.get(logoutUrl); - keycloak.server.log(['debug', 'keycloak'], 'Successfully signed out from the authentication server.'); - } catch (ex) { - keycloak.server.log(['warn', 'keycloak'], `An error occurred during signing out from the authentication server. Error: ${ex}`); - } - - return reply.redirect(baseUrl); + const redirectUrl = keycloak.getBaseUrl(request); + const logoutUrl = keycloak.getLogoutUrl({redirectUrl}); + return reply.redirect(logoutUrl); }, config: { - auth: false + auth: false, + cors: { + origin: keycloak.config.corsOrigin + } } }); }; - /* This is a plugin registration backward-compatible with Hapijs v14+ */ const register = (server, options, next) => { const adapter = new KeycloakAdapter(server, options); From 26818dc79a561acaa622f9618c31850cb8f0998c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Noco=C5=84?= Date: Mon, 31 Dec 2018 12:15:09 +0100 Subject: [PATCH 3/3] Update yar version in peer dependencies, move token rules outside --- package.json | 2 +- src/index.js | 82 ++++++++++++++++++++++++---------------------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index 0c3220c..fc3b179 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "peerDependencies": { "hapi": "^17.0.0", - "yar": "^9.0.1" + "yar": "^9.1.0" }, "engines": { "node": ">=8", diff --git a/src/index.js b/src/index.js index 77f83e3..ff33e57 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,33 @@ const urljoin = require('url-join'); const getProtocol = (request) => request.headers['x-forwarded-proto'] || request.server.info.protocol; const getHost = (request) => request.headers['x-forwarded-host'] || request.info.host; +const throwError = (message) => { + throw new Error(message) +}; + +const tokenRules = { + exists: (token) => token || throwError('Invalid token (missing)'), + notExpired: (token) => (token.content.exp || token.content.expiration) * 1000 > Date.now() || throwError('Invalid token (expired)'), + signed: (token) => token.signed || throwError('Invalid token (not signed)'), + validAction: (token, action) => token.content.action === action || throwError('Invalid token (wrong action)'), + validResource: (token, resource) => token.content.resource === resource || throwError('Invalid token (wrong resource)'), + validSignature: async (token, grantManager) => { + const verify = crypto.createVerify('RSA-SHA256'); + if (grantManager.publicKey) { + verify.update(token.signed); + if (!verify.verify(grantManager.publicKey, token.signature, 'base64')) { + throwError('Invalid token (signature)') + } + } else { + const key = await grantManager.rotation.getJWK(token.header.kid); + verify.update(token.signed); + if (!verify.verify(key, token.signature)) { + throwError('Invalid token (signature)') + } + } + } +}; + class ActionTokenVerifier { constructor(grantManager) { @@ -19,50 +46,12 @@ class ActionTokenVerifier { } async verify(token, {action, resource}) { - - const tokenRules = [ - { - verify: () => token, - error: new Error('Invalid token (missing)') - }, - { - verify: () => token.content.expiration * 1000 > Date.now(), - error: new Error('Invalid token (expired)') - }, - { - verify: () => token.signed, - error: new Error('Invalid token (not signed)') - }, - { - verify: () => token.content.action === action, - error: new Error('Invalid token (wrong action)') - }, - { - verify: () => token.content.resource === resource, - error: new Error('Invalid token (wrong resource)') - }, - { - verify: async () => { - const verify = crypto.createVerify('RSA-SHA256'); - if (this.grantManager.publicKey) { - verify.update(token.signed); - return verify.verify(this.grantManager.publicKey, token.signature, 'base64'); - } else { - const key = await this.grantManager.rotation.getJWK(token.header.kid); - verify.update(token.signed); - return verify.verify(key, token.signature) - } - }, - error: new Error('Invalid token (signature)') - } - ]; - - for (const rule of tokenRules) { - if (!await rule.verify()) { - throw rule.error; - } - } - + tokenRules.exists(token); + tokenRules.notExpired(token); + tokenRules.signed(token); + tokenRules.validAction(token, action); + tokenRules.validResource(token, resource); + await tokenRules.validSignature(token, this.grantManager); return token; } @@ -497,7 +486,10 @@ const registerBackChannelLogoutRoute = (keycloak) => { const logoutToken = new Token(request.payload); try { - await keycloak.actionTokenVerifier.verify(logoutToken, {action: 'LOGOUT', resource: keycloak.config.clientId}); + await keycloak.actionTokenVerifier.verify(logoutToken, { + action: 'LOGOUT', + resource: keycloak.config.clientId + }); } catch (ex) { const message = `Invalid token has been provided. ${ex}`; keycloak.server.log(['warn', 'keycloak'], message);