UNPKG

keycloak-connect

Version:
539 lines (483 loc) 17.4 kB
/*! * Copyright 2014 Red Hat, Inc. * * 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. */ 'use strict' const URL = require('url') const http = require('http') const https = require('https') const crypto = require('crypto') const querystring = require('querystring') const Grant = require('./grant') const Token = require('./token') const Rotation = require('./rotation') /** * Construct a grant manager. * * @param {Config} config Config object. * * @constructor */ function GrantManager (config) { this.realmUrl = config.realmUrl this.clientId = config.clientId this.secret = config.secret this.publicKey = config.publicKey this.public = config.public this.bearerOnly = config.bearerOnly this.notBefore = 0 this.rotation = new Rotation(config) this.verifyTokenAudience = config.verifyTokenAudience } /** * Use the direct grant API to obtain a grant from Keycloak. * * The direct grant API must be enabled for the configured realm * for this method to work. This function ostensibly provides a * non-interactive, programatic way to login to a Keycloak realm. * * This method can either accept a callback as the last parameter * or return a promise. * * @param {String} username The username. * @param {String} password The cleartext password. * @param {Function} callback Optional callback, if not using promises. */ GrantManager.prototype.obtainDirectly = function obtainDirectly (username, password, callback, scopeParam) { const params = { client_id: this.clientId, username, password, grant_type: 'password', scope: scopeParam || 'openid' } const handler = createHandler(this) const options = postOptions(this) return nodeify(fetch(this, handler, options, params), callback) } /** * Obtain a grant from a previous interactive login which results in a code. * * This is typically used by servers which receive the code through a * redirect_uri when sending a user to Keycloak for an interactive login. * * An optional session ID and host may be provided if there is desire for * Keycloak to be aware of this information. They may be used by Keycloak * when session invalidation is triggered from the Keycloak console itself * during its postbacks to `/k_logout` on the server. * * This method returns or promise or may optionally take a callback function. * * @param {String} code The code from a successful login redirected from Keycloak. * @param {String} sessionId Optional opaque session-id. * @param {String} sessionHost Optional session host for targetted Keycloak console post-backs. * @param {Function} callback Optional callback, if not using promises. */ GrantManager.prototype.obtainFromCode = function obtainFromCode (request, code, sessionId, sessionHost, callback) { const params = { client_session_state: sessionId, client_session_host: sessionHost, code, grant_type: 'authorization_code', client_id: this.clientId, redirect_uri: request.session ? request.session.auth_redirect_uri : {} } const handler = createHandler(this) const options = postOptions(this) return nodeify(fetch(this, handler, options, params), callback) } GrantManager.prototype.checkPermissions = function obtainPermissions (authzRequest, request, callback) { const params = { grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket' } if (authzRequest.audience) { params.audience = authzRequest.audience } else { params.audience = this.clientId } if (authzRequest.response_mode) { params.response_mode = authzRequest.response_mode } if (authzRequest.claim_token) { params.claim_token = authzRequest.claim_token params.claim_token_format = authzRequest.claim_token_format } const options = postOptions(this) if (this.public) { if (request.kauth && request.kauth.grant && request.kauth.grant.access_token) { options.headers.Authorization = 'Bearer ' + request.kauth.grant.access_token.token } } else { const header = request.headers.authorization let bearerToken if (header && (header.indexOf('bearer ') === 0 || header.indexOf('Bearer ') === 0)) { bearerToken = header.substring(7) } if (!bearerToken) { if (request.kauth && request.kauth.grant && request.kauth.grant.access_token) { bearerToken = request.kauth.grant.access_token.token } else { return Promise.reject(new Error('No bearer in header')) } } params.subject_token = bearerToken } let permissions = authzRequest.permissions if (!permissions) { permissions = [] } for (let i = 0; i < permissions.length; i++) { const resource = permissions[i] let permission = resource.id if (resource.scopes && resource.scopes.length > 0) { permission += '#' for (let j = 0; j < resource.scopes.length; j++) { const scope = resource.scopes[j] if (permission.indexOf('#') !== permission.length - 1) { permission += ',' } permission += scope } } if (!params.permission) { params.permission = [] } params.permission.push(permission) } const manager = this const handler = (resolve, reject, json) => { try { if (authzRequest.response_mode === 'decision' || authzRequest.response_mode === 'permissions') { callback(JSON.parse(json)) } else { resolve(manager.createGrant(json)) } } catch (err) { reject(err) } } return nodeify(fetch(this, handler, options, params)) } /** * Obtain a service account grant. * Client option 'Service Accounts Enabled' needs to be on. * * This method returns or promise or may optionally take a callback function. * * @param {Function} callback Optional callback, if not using promises. */ GrantManager.prototype.obtainFromClientCredentials = function obtainFromlientCredentials (callback, scopeParam) { const params = { grant_type: 'client_credentials', scope: scopeParam || 'openid', client_id: this.clientId } const handler = createHandler(this) const options = postOptions(this) return nodeify(fetch(this, handler, options, params), callback) } /** * Ensure that a grant is *fresh*, refreshing if required & possible. * * If the access_token is not expired, the grant is left untouched. * * If the access_token is expired, and a refresh_token is available, * the grant is refreshed, in place (no new object is created), * and returned. * * If the access_token is expired and no refresh_token is available, * an error is provided. * * The method may either return a promise or take an optional callback. * * @param {Grant} grant The grant object to ensure freshness of. * @param {Function} callback Optional callback if promises are not used. */ GrantManager.prototype.ensureFreshness = function ensureFreshness (grant, callback) { if (!grant.isExpired()) { return nodeify(Promise.resolve(grant), callback) } if (!grant.refresh_token) { return nodeify(Promise.reject(new Error('Unable to refresh without a refresh token')), callback) } if (grant.refresh_token.isExpired()) { return nodeify(Promise.reject(new Error('Unable to refresh with expired refresh token')), callback) } const params = { grant_type: 'refresh_token', refresh_token: grant.refresh_token.token, client_id: this.clientId } const handler = refreshHandler(this, grant) const options = postOptions(this) return nodeify(fetch(this, handler, options, params), callback) } /** * Perform live validation of an `access_token` against the Keycloak server. * * @param {Token|String} token The token to validate. * @param {Function} callback Callback function if not using promises. * * @return {boolean} `false` if the token is invalid, or the same token if valid. */ GrantManager.prototype.validateAccessToken = function validateAccessToken (token, callback) { let t = token if (typeof token === 'object') { t = token.token } const params = { token: t, client_secret: this.secret, client_id: this.clientId } const options = postOptions(this, '/protocol/openid-connect/token/introspect') const handler = validationHandler(this, token) return nodeify(fetch(this, handler, options, params), callback) } GrantManager.prototype.userInfo = function userInfo (token, callback) { const url = this.realmUrl + '/protocol/openid-connect/userinfo' const options = URL.parse(url); // eslint-disable-line options.method = 'GET' let t = token if (typeof token === 'object') t = token.token options.headers = { Authorization: 'Bearer ' + t, Accept: 'application/json', 'X-Client': 'keycloak-nodejs-connect' } const promise = new Promise((resolve, reject) => { const req = getProtocol(options).request(options, (response) => { if (response.statusCode < 200 || response.statusCode >= 300) { return reject(new Error('Error fetching account')) } let json = '' response.on('data', (d) => (json += d.toString())) response.on('end', () => { const data = JSON.parse(json) if (data.error) reject(data) else resolve(data) }) }) req.on('error', reject) req.end() }) return nodeify(promise, callback) } GrantManager.prototype.getAccount = function getAccount () { console.error('GrantManager#getAccount is deprecated. See GrantManager#userInfo') return this.userInfo.apply(this, arguments) } GrantManager.prototype.isGrantRefreshable = function isGrantRefreshable (grant) { return !this.bearerOnly && (grant && grant.refresh_token) } /** * Create a `Grant` object from a string of JSON data. * * This method creates the `Grant` object, including * the `access_token`, `refresh_token` and `id_token` * if available, and validates each for expiration and * against the known public-key of the server. * * @param {String} rawData The raw JSON string received from the Keycloak server or from a client. * @return {Promise} A promise reoslving a grant. */ GrantManager.prototype.createGrant = function createGrant (rawData) { let grantData = rawData if (typeof rawData !== 'object') grantData = JSON.parse(grantData) const grant = new Grant({ access_token: (grantData.access_token ? new Token(grantData.access_token, this.clientId) : undefined), refresh_token: (grantData.refresh_token ? new Token(grantData.refresh_token) : undefined), id_token: (grantData.id_token ? new Token(grantData.id_token) : undefined), expires_in: grantData.expires_in, token_type: grantData.token_type, __raw: rawData }) if (this.isGrantRefreshable(grant)) { return new Promise((resolve, reject) => { this.ensureFreshness(grant) .then(g => this.validateGrant(g)) .then(g => resolve(g)) .catch(err => reject(err)) }) } else { return this.validateGrant(grant) } } /** * Validate the grant and all tokens contained therein. * * This method examines a grant (in place) and rejects * if any of the tokens are invalid. After this method * resolves, the passed grant is guaranteed to have * valid tokens. * * @param {Grant} The grant to validate. * * @return {Promise} That resolves to a validated grant or * rejects with an error if any of the tokens are invalid. */ GrantManager.prototype.validateGrant = function validateGrant (grant) { const self = this const validateGrantToken = (grant, tokenName, expectedType) => { return new Promise((resolve, reject) => { // check the access token this.validateToken(grant[tokenName], expectedType).then(token => { grant[tokenName] = token resolve() }).catch((err) => { reject(new Error('Grant validation failed. Reason: ' + err.message)) }) }) } return new Promise((resolve, reject) => { const promises = [] promises.push(validateGrantToken(grant, 'access_token', 'Bearer')) if (!self.bearerOnly) { if (grant.id_token) { promises.push(validateGrantToken(grant, 'id_token', 'ID')) } } Promise.all(promises).then(() => { resolve(grant) }).catch((err) => { reject(new Error(err.message)) }) }) } /** * Validate a token. * * This method accepts a token, and returns a promise * * If the token is valid the promise will be resolved with the token * * If the token is undefined or fails validation an applicable error is returned * * @return {Promise} That resolve a token */ GrantManager.prototype.validateToken = function validateToken (token, expectedType) { return new Promise((resolve, reject) => { if (!token) { reject(new Error('invalid token (missing)')) } else if (token.isExpired()) { reject(new Error('invalid token (expired)')) } else if (!token.signed) { reject(new Error('invalid token (not signed)')) } else if (token.content.typ !== expectedType) { reject(new Error('invalid token (wrong type)')) } else if (token.content.iat < this.notBefore) { reject(new Error('invalid token (stale token)')) } else if (token.content.iss !== this.realmUrl) { reject(new Error('invalid token (wrong ISS)')) } else { const audienceData = Array.isArray(token.content.aud) ? token.content.aud : [token.content.aud] if (expectedType === 'ID') { if (!audienceData.includes(this.clientId)) { reject(new Error('invalid token (wrong audience)')) } if (token.content.azp && token.content.azp !== this.clientId) { reject(new Error('invalid token (authorized party should match client id)')) } } else if (this.verifyTokenAudience) { if (!audienceData.includes(this.clientId)) { reject(new Error('invalid token (wrong audience)')) } } const verify = crypto.createVerify('RSA-SHA256') // if public key has been supplied use it to validate token if (this.publicKey) { try { verify.update(token.signed) if (!verify.verify(this.publicKey, token.signature, 'base64')) { reject(new Error('invalid token (signature)')) } else { resolve(token) } } catch (err) { reject(new Error('Misconfigured parameters while validating token. Check your keycloak.json file!')) } } else { // retrieve public KEY and use it to validate token this.rotation.getJWK(token.header.kid).then(key => { verify.update(token.signed) if (!verify.verify(key, token.signature)) { reject(new Error('invalid token (public key signature)')) } else { resolve(token) } }).catch((err) => { reject(new Error('failed to load public key to verify token. Reason: ' + err.message)) }) } } }) } const getProtocol = (opts) => { return opts.protocol === 'https:' ? https : http } const nodeify = (promise, cb) => { if (typeof cb !== 'function') return promise return promise.then((res) => cb(null, res)).catch((err) => cb(err)) } const createHandler = (manager) => (resolve, reject, json) => { try { resolve(manager.createGrant(json)) } catch (err) { reject(err) } } const refreshHandler = (manager, grant) => (resolve, reject, json) => { manager.createGrant(json) .then((grant) => resolve(grant)) .catch((err) => reject(err)) } const validationHandler = (manager, token) => (resolve, reject, json) => { const data = JSON.parse(json) if (!data.active) resolve(false) else resolve(token) } const postOptions = (manager, path) => { const realPath = path || '/protocol/openid-connect/token' const opts = URL.parse(manager.realmUrl + realPath); // eslint-disable-line opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Client': 'keycloak-nodejs-connect' } if (!manager.public) { opts.headers.Authorization = 'Basic ' + Buffer.from(manager.clientId + ':' + manager.secret).toString('base64') } opts.method = 'POST' return opts } const fetch = (manager, handler, options, params) => { return new Promise((resolve, reject) => { const data = (typeof params === 'string' ? params : querystring.stringify(params)) options.headers['Content-Length'] = data.length const req = getProtocol(options).request(options, (response) => { if (response.statusCode < 200 || response.statusCode > 299) { return reject(new Error(response.statusCode + ':' + http.STATUS_CODES[response.statusCode])) } let json = '' response.on('data', (d) => (json += d.toString())) response.on('end', () => { handler(resolve, reject, json) }) }) req.write(data) req.on('error', reject) req.end() }) } module.exports = GrantManager