UNPKG

keycloak-connect-multirealm

Version:

Keycloak Node.js Adapter With Support for multiple realms

194 lines (177 loc) 6.4 kB
const Keycloak = require('keycloak-connect'); const NodeCache = require('node-cache'); const composable = require('composable-middleware'); const jwt = require('jsonwebtoken'); const path = require('path'); const fs = require('fs'); const Admin = require(path.join(process.cwd(), './node_modules/keycloak-connect/middleware/admin')); const Logout = require(path.join(process.cwd(), './node_modules/keycloak-connect/middleware/logout')); const PostAuth = require(path.join(process.cwd(), './node_modules/keycloak-connect/middleware/post-auth')); const GrantAttacher = require(path.join(process.cwd(), './node_modules/keycloak-connect/middleware/grant-attacher')); const Protect = require(path.join(process.cwd(), './node_modules/keycloak-connect/middleware/protect')); /** * We do not use clones because NodeCache throws exception * if the Keycloak is initiated with a non memory store(like redis-store). * Whenever keycloak is initialised with redis-store, the keycloak object contains * some deeply nested http connection objects, NodeCache fails because clone fails, * hence useClones is set to false. */ const cache = new NodeCache({'useClones': false}); const defaultOptions = { 'admin': '/', 'logout': '/logout', }; module.exports = class { constructor(config, keycloakConfig) { if (!config) { throw new Error('Adapter configuration must be provided.'); } this.config = config; this.keycloakConfig = this._getKeycloakConfig(keycloakConfig); } middleware(customOptions) { const options = Object.assign({}, defaultOptions, customOptions); return (req, res, next) => { const realm = this.getRealmName(req); if (!realm) { return next(); } req.kauth = {realm}; const keycloakObject = this.getKeycloakObjectForRealm(realm); /* eslint-disable new-cap */ const middleware = composable( PostAuth(keycloakObject), Admin(keycloakObject, options.admin), GrantAttacher(keycloakObject), Logout(keycloakObject, options.logout), ); /* eslint-enable new-cap */ middleware(req, res, next); }; } protect(spec) { return (req, res, next) => { const realm = this.getRealmName(req); if (!realm) { return this.accessDenied(req, res); } const keycloakObject = this.getKeycloakObjectForRealm(realm); // eslint-disable-next-line new-cap Protect(keycloakObject, spec)(req, res, next); }; } getRealmName(req) { const token = this._decodeTokenString(this._getTokenStringFromRequest(req)); if (token && token.payload && token.payload.iss && token.payload.iss.startsWith(this.keycloakConfig['auth-server-url'])) { return this.getRealmNameFromToken(token); } return this.getRealmNameFromRequest(req); } getRealmNameFromToken(token) { return token.payload.iss.split('/').pop(); } /** * Method that should return the realm name for the given request. * * It will be called when the request doesn't have a valid token. * * By default it's empty, so it must be implemented by the user. * If not implemented, the admin and logout endpoints won't work. * * @param {Object} request The HTTP request. */ // eslint-disable-next-line no-unused-vars getRealmNameFromRequest(req) { // should be implemented by user } /** * It creates a (or returns a cached) keycloak object for the given realm. * * @param {string} realm The realm name * @returns {Object} The keycloak object */ getKeycloakObjectForRealm(realm) { let keycloakObject = cache.get(realm); if (keycloakObject) { return keycloakObject; } const keycloakConfig = Object.assign({}, this.keycloakConfig, {realm}); keycloakObject = new Keycloak(this.config, keycloakConfig); keycloakObject.authenticated = this.authenticated; keycloakObject.deauthenticated = this.deauthenticated; keycloakObject.accessDenied = this.accessDenied; cache.set(realm, keycloakObject); return keycloakObject; } /** * Replaceable function to handle access-denied responses. * * In the event the Keycloak middleware decides a user may * not access a resource, or has failed to authenticate at all, * this function will be called. * * By default, a simple string of "Access denied" along with * an HTTP status code for 403 is returned. Chances are an * application would prefer to render a fancy template. */ accessDenied(req, res) { res.status(403).send('Access Denied'); } /** * Callback made upon successful authentication of a user. * * By default, this a no-op, but may assigned to another * function for application-specific login which may be useful * for linking authentication information from Keycloak to * application-maintained user information. * * The `request.kauth.grant` object contains the relevant tokens * which may be inspected. * * For instance, to obtain the unique subject ID: * * request.kauth.grant.id_token.sub => bf2056df-3803-4e49-b3ba-ff2b07d86995 * * @param {Object} request The HTTP request. */ // eslint-disable-next-line no-unused-vars authenticated(req) { // no-op } /** * Callback made upon successful de-authentication of a user. * * By default, this is a no-op, but may be used by the application * in the case it needs to remove information from the user's session * or otherwise perform additional logic once a user is logged out. * * @param {Object} request The HTTP request. */ // eslint-disable-next-line no-unused-vars deauthenticated(req) { // no-op } _getKeycloakConfig(keycloakConfig) { if (typeof keycloakConfig === 'string') { return JSON.parse(fs.readFileSync(keycloakConfig)); } if (keycloakConfig) { return keycloakConfig; } return JSON.parse(fs.readFileSync(path.join(process.cwd(), 'keycloak.json'))); } _decodeTokenString(tokenString) { return jwt.decode(tokenString, {'complete': true}); } _getTokenStringFromRequest(req) { const authorization = req.headers.authorization || req.headers.Authorization; if (!authorization) { return; } if (authorization.toLowerCase().startsWith('bearer')) { return authorization.split(' ').pop(); } return authorization; } };