keycloak-connect
Version:
Keycloak Connect Middleware
431 lines (386 loc) • 15.8 kB
JavaScript
/*
* 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