Skip to content

Commit

Permalink
Merge pull request #4 from novomatic-tech/feature/back-channel-logout
Browse files Browse the repository at this point in the history
Implement the Back-Channel Logout mechanism
  • Loading branch information
tomnocon authored Dec 31, 2018
2 parents db96d35 + 26818dc commit 30d92cc
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 36 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ typings/

.idea/
*.iml

# Output of 'npm run build'
dist
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,8 @@ Parameter | Description | 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).
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -35,7 +35,7 @@
},
"peerDependencies": {
"hapi": "^17.0.0",
"yar": "^9.0.1"
"yar": "^9.1.0"
},
"engines": {
"node": ">=8",
Expand Down
154 changes: 121 additions & 33 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,60 @@ const UUID = require('keycloak-connect/uuid');
const Boom = require('boom');
const _ = require('lodash');
const pkg = require('../package.json');
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;

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) {
this.grantManager = grantManager;
}

async verify(token, {action, resource}) {
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;
}

}

class SessionGrantStore {
constructor(options = null) {
this.options = Object.assign({
this.options = Object.assign({
key: 'kc_auth_grant'
}, options);
this.name = 'session';
Expand Down Expand Up @@ -141,7 +187,7 @@ const createPrincipalResource = (principal) => {
if (!principal) {
return principal;
}
const { name, scope, accessToken, idToken } = principal;
const {name, scope, accessToken, idToken} = principal;
const formattedPrincipal = {
name,
scope,
Expand Down Expand Up @@ -190,12 +236,14 @@ class KeycloakAdapter {
principalConversion: defaultPrincipalConversion,
principalNameAttribute: 'name',
shouldRedirectUnauthenticated: defaultShouldRedirectUnauthenticated(config),
cacheName: '_default'
}, config);
if (!this.config.secret) {
this.config.secret = this.config.clientSecret;
}
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'
Expand All @@ -213,13 +261,13 @@ class KeycloakAdapter {
return stores;
}

obtainGrantFromCode(code, redirectUri) {
const req = {
session: { auth_redirect_uri: redirectUri }
obtainGrantFromCode(code, redirectUri, sessionId, sessionHost) {
const req = {
session: {auth_redirect_uri: redirectUri}
};
return this.grantManager.obtainFromCode(req, code);
return this.grantManager.obtainFromCode(req, code, sessionId, sessionHost);
}

getLoginUrl(redirectUrl, stateUuid = null) {
return this.keycloakConfig.realmUrl +
'/protocol/openid-connect/auth' +
Expand All @@ -230,10 +278,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() {
Expand All @@ -253,7 +303,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', []);
Expand Down Expand Up @@ -291,7 +341,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;
Expand All @@ -307,19 +357,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}));
}
}
}
};
};
Expand Down Expand Up @@ -353,6 +403,7 @@ class KeycloakAdapter {
if (!this.config.bearerOnly) {
registerLoginRoute(this);
registerLogoutRoute(this);
registerBackChannelLogoutRoute(this);
}
if (this.config.principalUrl) {
registerPrincipalRoute(this);
Expand Down Expand Up @@ -405,11 +456,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, 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));
} 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));
Expand All @@ -425,6 +476,43 @@ const registerLoginRoute = (keycloak) => {
});
};

const registerBackChannelLogoutRoute = (keycloak) => {
keycloak.server.route({
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(sessionId => keycloak.server.yar.revoke(sessionId)));
} catch (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: {
auth: false
}
});
};

const registerLogoutRoute = (keycloak) => {
keycloak.server.route({
path: keycloak.config.logoutUrl,
Expand All @@ -434,7 +522,7 @@ const registerLogoutRoute = (keycloak) => {
const grantStore = keycloak.getGrantStoreByName('session');
grantStore.clearGrant(request);
const redirectUrl = keycloak.getBaseUrl(request);
const logoutUrl = keycloak.getLogoutUrl(redirectUrl);
const logoutUrl = keycloak.getLogoutUrl({redirectUrl});
return reply.redirect(logoutUrl);
},
config: {
Expand All @@ -454,9 +542,9 @@ const register = (server, options, next) => {
next();
}
};
register.attributes = { pkg };
register.attributes = {pkg};
module.exports = {
register,
pkg,
KeycloakAdapter
register,
pkg,
KeycloakAdapter
};

0 comments on commit 30d92cc

Please sign in to comment.