UNPKG

keycloak-connect

Version:
431 lines (386 loc) 15.8 kB
/* * Copyright 2016 Red Hat Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with 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 BearerStore = require('./stores/bearer-store') const CookieStore = require('./stores/cookie-store') const SessionStore = require('./stores/session-store') const Config = require('./middleware/auth-utils/config') const GrantManager = require('./middleware/auth-utils/grant-manager') const Setup = require('./middleware/setup') const Admin = require('./middleware/admin') const Logout = require('./middleware/logout') const PostAuth = require('./middleware/post-auth') const GrantAttacher = require('./middleware/grant-attacher') const Protect = require('./middleware/protect') const Enforcer = require('./middleware/enforcer') const CheckSso = require('./middleware/check-sso') /** * Instantiate a Keycloak. * * The `config` and `keycloakConfig` hashes are both optional. * * The `config` hash, if provided, may include either `store`, pointing * to the actual session-store used by your application, or `cookies` * with boolean `true` as the value to support using cookies as your * authentication store. Bear in mind that cookies session store expects * a cookie parser to be present, e.g. "cookie-parser" in case of Express.js. * * A session-based store is recommended, as it allows more assured control * from the Keycloak console to explicitly logout some or all sessions. * * In all cases, also, authentication through a Bearer authentication * header is supported for non-interactive APIs. * * The `keycloakConfig` object, by default, is populated by the contents of * a `keycloak.json` file installed alongside your application, copied from * the Keycloak administration console when you provision your application. * * @constructor * * @param {Object} config Configuration for the Keycloak connector. * @param {Object} keycloakConfig Keycloak-specific configuration. * * @return {Keycloak} A constructed Keycloak object. * */ function Keycloak (config, keycloakConfig) { // If keycloakConfig is null, Config() will search for `keycloak.json`. this.config = new Config(keycloakConfig) this.grantManager = new GrantManager(this.config) this.stores = [BearerStore] if (!config) { throw new Error('Adapter configuration must be provided.') } // Add the custom scope value this.config.scope = config.scope if (config && config.store && config.cookies) { throw new Error('Either `store` or `cookies` may be set, but not both') } if (config && config.store) { this.stores.push(new SessionStore(config.store)) } else if (config && config.cookies) { this.stores.push(CookieStore) } this.config.idpHint = config.idpHint } /** * Obtain an array of middleware for use in your application. * * Generally this should be installed at the root of your application, * as it provides general wiring for Keycloak interaction, without actually * causing Keycloak to get involved with any particular URL until asked * by using `protect(...)`. * * Example: * * var app = express(); * var keycloak = new Keycloak(); * app.use( keycloak.middleware() ); * * Options: * * - `logout` URL for logging a user out. Defaults to `/logout`. * - `admin` Root URL for Keycloak admin callbacks. Defaults to `/`. * * @param {Object} options Optional options for specifying details. */ Keycloak.prototype.middleware = function (options) { if (!options) { options = { logout: '', admin: '' } } options.logout = options.logout || '/logout' options.admin = options.admin || '/' const middlewares = [] middlewares.push(Setup) middlewares.push(PostAuth(this)) middlewares.push(Admin(this, options.admin)) middlewares.push(GrantAttacher(this)) middlewares.push(Logout(this, options.logout)) return middlewares } /** * Apply protection middleware to an application or specific URL. * * If no `spec` parameter is provided, the subsequent handlers will * be invoked if the user is authenticated, regardless of what roles * he or she may or may not have. * * If a user is not currently authenticated, the middleware will cause * the authentication workflow to begin by redirecting the user to the * Keycloak installation to login. Upon successful login, the user will * be redirected back to the originally-requested URL, fully-authenticated. * * If a `spec` is provided, the same flow as above will occur to ensure that * a user it authenticated. Once authenticated, the spec will then be evaluated * to determine if the user may or may not access the following resource. * * The `spec` may be either a `String`, specifying a single required role, * or a function to make more fine-grained determination about access-control * * If the `spec` is a `String`, then the string will be interpreted as a * role-specification according to the following rules: * * - If the string starts with `realm:`, the suffix is treated as the name * of a realm-level role that is required for the user to have access. * - If the string contains a colon, the portion before the colon is treated * as the name of an application within the realm, and the portion after the * colon is treated as a role within that application. The user then must have * the named role within the named application to proceed. * - If the string contains no colon, the entire string is interpreted as * as the name of a role within the current application (defined through * the installed `keycloak.json` as provisioned within Keycloak) that the * user must have in order to proceed. * * Example * * // Users must have the `special-people` role within this application * app.get( '/special/:page', keycloak.protect( 'special-people' ), mySpecialHandler ); * * If the `spec` is a function, it may take up to two parameters in order to * assist it in making an authorization decision: the access token, and the * current HTTP request. It should return `true` if access is allowed, otherwise * `false`. * * The `token` object has a method `hasRole(...)` which follows the same rules * as above for `String`-based specs. * * // Ensure that users have either `nicepants` realm-level role, or `mr-fancypants` app-level role. * function pants(token, request) { * return token.hasRole( 'realm:nicepants') || token.hasRole( 'mr-fancypants'); * } * * app.get( '/fancy/:page', keycloak.protect( pants ), myPantsHandler ); * * With no spec, simple authentication is all that is required: * * app.get( '/complain', keycloak.protect(), complaintHandler ); * * @param {String} spec The protection spec (optional) */ Keycloak.prototype.protect = function (spec) { return Protect(this, spec) } /** * Enforce access based on the given permissions. This method operates in two modes, depending on the `response_mode` * defined for this policy enforcer. * * If `response_mode` is set to `token`, permissions are obtained using an specific grant type. As a consequence, the * token with the permissions granted by the server is updated and made available to the application via `request.kauth.grant.access_token`. * Use this mode when your application is using sessions and you want to cache previous decisions from the server, as well automatically handle * refresh tokens. This mode is especially useful for applications acting as client and resource server. * * If `response_mode` is set to `permissions`, the server only returns the list of granted permissions (no oauth2 response). * Previous decisions are not cached and the policy enforcer will query the server every time to get a decision. * This is the default `response_mode`. * * You can set `response_mode` as follows: * * keycloak.enforcer('item:read', {response_mode: 'token'}) * * In all cases, if the request is already populated with a valid access token (for instance, bearer tokens sent by clients to the application), * the policy enforcer will first try to resolve permissions from the current token before querying the server. * * By default, the policy enforcer will use the `client_id` defined to the application (for instance, via `keycloak.json`) to * reference a client in Keycloak that supports Keycloak Authorization Services. In this case, the client can not be public given * that it is actually a resource server. * * If your application is acting as a client and resource server, you can use the following configuration to specify the client * in Keycloak with the authorization settings: * * keycloak.enforcer('item:read', {resource_server_id: 'nodejs-apiserver'}) * * It is recommended to use separated clients in Keycloak to represent your frontend and backend. * * If the application you are protecting is enabled with Keycloak authorization services and you have defined client credentials * in `keycloak.json`, you can push additional claims to the server and make them available to your policies in order to make decisions. * For that, you can define a `claims` configuration option which expects a `function` that returns a JSON with the claims you want to push: * * app.get('/protected/resource', keycloak.enforcer(['resource:view', 'resource:write'], { claims: function(request) { return { "http.uri": ["/protected/resource"], "user.agent": // get user agent from request } } }), function (req, res) { // access granted }); * * @param {String[]} expectedPermissions A single string representing a permission or an arrat of strings representing the permissions. For instance, 'item:read' or ['item:read', 'item:write']. */ Keycloak.prototype.enforcer = function (permissions, config) { return new Enforcer(this, config).enforce(permissions) } /** * Apply check SSO middleware to an application or specific URL. * * Check SSO will only authenticate the client if the user is already logged-in, * if the user is not logged-in the browser will be redirected back * to the originally-requested URL and remain unauthenticated. * */ Keycloak.prototype.checkSso = function () { return CheckSso(this) } /** * 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. */ Keycloak.prototype.authenticated = function (request) { // 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. */ Keycloak.prototype.deauthenticated = function (request) { // no-op } /** * 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. */ Keycloak.prototype.accessDenied = function (request, response) { response.status(403) response.end('Access denied') } /*! ignore */ Keycloak.prototype.getGrant = function (request, response) { let rawData for (let i = 0; i < this.stores.length; ++i) { rawData = this.stores[i].get(request) if (rawData) { // store = this.stores[i]; break } } let grantData = rawData if (typeof (grantData) === 'string') { grantData = JSON.parse(grantData) } if (grantData && !grantData.error) { const self = this return this.grantManager.createGrant(JSON.stringify(grantData)) .then(grant => { self.storeGrant(grant, request, response) return grant }) .catch(() => { return Promise.reject(new Error('Could not store grant code error')) }) } return Promise.reject(new Error('Could not obtain grant code error')) } Keycloak.prototype.storeGrant = function (grant, request, response) { if (this.stores.length < 2 || BearerStore.get(request)) { // cannot store bearer-only, and should not store if grant is from the // authorization header return } if (!grant) { this.accessDenied(request, response) return } this.stores[1].wrap(grant) grant.store(request, response) return grant } Keycloak.prototype.unstoreGrant = function (sessionId) { if (this.stores.length < 2) { // cannot unstore, bearer-only, this is weird return } this.stores[1].clear(sessionId) } Keycloak.prototype.getGrantFromCode = function (code, request, response) { if (this.stores.length < 2) { // bearer-only, cannot do this; throw new Error('Cannot exchange code for grant in bearer-only mode') } const sessionId = request.session.id const self = this return this.grantManager.obtainFromCode(request, code, sessionId) .then(function (grant) { self.storeGrant(grant, request, response) return grant }) } Keycloak.prototype.checkPermissions = function (authzRequest, request, callback) { const self = this return this.grantManager.checkPermissions(authzRequest, request, callback) .then(function (grant) { if (!authzRequest.response_mode) { self.storeGrant(grant, request) } return grant }) } Keycloak.prototype.loginUrl = function (uuid, redirectUrl) { let url = this.config.realmUrl + '/protocol/openid-connect/auth' + '?client_id=' + encodeURIComponent(this.config.clientId) + '&state=' + encodeURIComponent(uuid) + '&redirect_uri=' + encodeURIComponent(redirectUrl) + '&scope=' + encodeURIComponent(this.config.scope ? 'openid ' + this.config.scope : 'openid') + '&response_type=code' if (this.config && this.config.idpHint) { url += '&kc_idp_hint=' + encodeURIComponent(this.config.idpHint) } return url } Keycloak.prototype.logoutUrl = function (redirectUrl, idTokenHint) { const url = new URL(this.config.realmUrl + '/protocol/openid-connect/logout') if (redirectUrl && idTokenHint) { url.searchParams.set('id_token_hint', idTokenHint) url.searchParams.set('post_logout_redirect_uri', redirectUrl) } return url.toString() } Keycloak.prototype.accountUrl = function () { return this.config.realmUrl + '/account' } Keycloak.prototype.getAccount = function (token) { return this.grantManager.getAccount(token) } Keycloak.prototype.redirectToLogin = function (request) { return !this.config.bearerOnly } Keycloak.prototype.getConfig = function () { return this.config } module.exports = Keycloak