UNPKG

@cto.ai/ops

Version:

šŸ’» CTO.ai - The CLI built for Teams šŸš€

335 lines (334 loc) • 15.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.KeycloakService = void 0; const tslib_1 = require("tslib"); const config_1 = tslib_1.__importDefault(require("keycloak-connect/middleware/auth-utils/config")); const grant_manager_1 = tslib_1.__importDefault(require("keycloak-connect/middleware/auth-utils/grant-manager")); const hapi_1 = require("@hapi/hapi"); const v1_1 = tslib_1.__importDefault(require("uuid/v1")); const querystring_1 = tslib_1.__importDefault(require("querystring")); const path_1 = tslib_1.__importDefault(require("path")); const inert_1 = tslib_1.__importDefault(require("@hapi/inert")); const open_1 = tslib_1.__importDefault(require("open")); const debug_1 = tslib_1.__importDefault(require("debug")); const utils_1 = require("./../utils"); const cli_sdk_1 = require("@cto.ai/cli-sdk"); const axios_1 = tslib_1.__importDefault(require("axios")); const CustomErrors_1 = require("./../errors/CustomErrors"); const env_1 = require("./../constants/env"); const jsonwebtoken_1 = tslib_1.__importDefault(require("jsonwebtoken")); const debug = (0, debug_1.default)('ops:KeycloakService'); const KEYCLOAK_CONFIG = { realm: 'ops', 'auth-server-url': env_1.OPS_KEYCLOAK_HOST, 'ssl-required': 'external', resource: 'ops-cli', 'public-client': true, 'confidential-port': 0, }; /** * The token endpoint which provides fresh tokens * e.g. * http://localhost:8080/auth/realms/ops/protocol/openid-connect/token * * for more info see: https://uaa.prod-platform.hc.ai/auth/realms/ops/.well-known/openid-configuration */ const keycloakTokenEndpoint = `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${KEYCLOAK_CONFIG.realm}/protocol/openid-connect/token`; const accountUrl = `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${KEYCLOAK_CONFIG.realm}/account/password`; class KeycloakService { constructor(grantManager = new grant_manager_1.default(new config_1.default(KEYCLOAK_CONFIG))) { this.grantManager = grantManager; this.KEYCLOAK_SIGNIN_FILEPATH = path_1.default.join(__dirname, '../keycloakPages/signinRedirect.html'); this.KEYCLOAK_SIGNUP_FILEPATH = env_1.NODE_ENV === 'staging' ? path_1.default.join(__dirname, '../keycloakPages/signupRedirect_staging.html') : path_1.default.join(__dirname, '../keycloakPages/signupRedirect.html'); this.KEYCLOAK_ERROR_FILEPATH = path_1.default.join(__dirname, '../keycloakPages/errorRedirect.html'); this.KEYCLOAK_REALM = 'ops'; this.CALLBACK_HOST = 'localhost'; this.CALLBACK_ENDPOINT = 'callback'; this.CLIENT_ID = 'ops-cli'; this.CALLBACK_PORT = null; this.CALLBACK_URL = null; this.POSSIBLE_PORTS = [10234, 28751, 38179, 41976, 49164]; this.hapiServer = {}; /** * Generates the required query string params for standard flow */ this._buildStandardFlowParams = () => { const data = { client_id: this.CLIENT_ID, redirect_uri: this.CALLBACK_URL, response_type: 'code', scope: 'openid token', nonce: (0, v1_1.default)(), state: (0, v1_1.default)(), }; return querystring_1.default.stringify(data); }; /** * Generates the initial URL with qury string parameters fire of to Keycloak * e.g. * http://localhost:8080/auth/realms/ops/protocol/openid-connect/auth? * client_id=cli& * redirect_uri=http%3A%2F%2Flocalhost%3A10234%2Fcallback& * response_type=code& * scope=openid%20token& * nonce=12345678-1234-1234 -1234-12345678& * state=12345678-1234-1234-1234-12345678 */ this._buildAuthorizeUrl = () => { const params = this._buildStandardFlowParams(); return `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${this.KEYCLOAK_REALM}/protocol/openid-connect/auth?${params}`; }; /** * Converts the Keycloak Grant object to Tokens */ this._formatGrantToTokens = (grant) => { if (!grant || !grant.access_token || !grant.refresh_token || !grant.id_token || !grant.access_token.content || !grant.access_token.content.session_state) { throw new CustomErrors_1.SSOError(); } const accessToken = grant.access_token.token; const refreshToken = grant.refresh_token.token; const idToken = grant.id_token.token; const sessionState = grant.access_token.content.session_state; if (!accessToken || !refreshToken || !idToken) throw new CustomErrors_1.SSOError(); return { accessToken, refreshToken, idToken, sessionState, }; }; /** * Opens the signin URL and sets up the server for callback */ this.keycloakSignInFlow = async () => { (0, open_1.default)(this._buildAuthorizeUrl()); const grant = await this._setupCallbackServerForGrant('signin'); return this._formatGrantToTokens(grant); }; /** * Generates the initial URL with qury string parameters fire of to Keycloak * e.g. * http://localhost:8080/auth/realms/ops/protocol/openid-connect/registrations? * client_id=www-dev * response_type=code */ this._buildRegisterUrl = () => { const params = this._buildStandardFlowParams(); return `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${this.KEYCLOAK_REALM}/protocol/openid-connect/registrations?${params}`; }; /** * Opens the signup link in the browser, and listen for it's response */ this.keycloakSignUpFlow = async () => { const registerUrl = this._buildRegisterUrl(); (0, open_1.default)(registerUrl); console.log(`\nšŸ’» Please follow the prompts in the browser window and verify your email address before logging in`); console.log(`\n If the link doesn't open, please click the following URL ${cli_sdk_1.ux.colors.dim(registerUrl)} \n\n`); const grant = await this._setupCallbackServerForGrant('signup'); return this._formatGrantToTokens(grant); }; /** * Generates the initial URL with query string parameters fired off to Keycloak * e.g. * http://localhost:8080/auth/realms/ops/login-actions/reset-credentials?client_id=cli */ this._buildResetUrl = () => { const data = { client_id: this.CLIENT_ID, }; const params = querystring_1.default.stringify(data); return `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${this.KEYCLOAK_REALM}/login-actions/reset-credentials?${params}`; }; this.keycloakResetFlow = (isUserSignedIn) => { // open up account page if the user is signed in otherwise open up password reset page const url = isUserSignedIn ? accountUrl : this._buildResetUrl(); (0, open_1.default)(url); console.log(`\nšŸ’» Please follow the prompts in the browser window`); console.log(`\n If the link doesn't open, please click the following URL: ${cli_sdk_1.ux.colors.dim(url)} \n\n`); }; this.refreshAccessToken = async (oldConfig, refreshToken) => { try { debug('Starting to refresh access token'); const decodedToken = jsonwebtoken_1.default.decode(refreshToken); // TODO: something is fishy here /* * the refresh token contains the client ID (azp). The client * ID in the request params has to match the client ID embedded * in the token. */ const clientName = decodedToken && decodedToken.azp ? decodedToken.azp : this.CLIENT_ID; /** * This endpoint expects a x-form-url-encoded header, not JSON */ const refreshData = querystring_1.default.stringify({ grant_type: 'refresh_token', client_id: clientName, refresh_token: refreshToken, }); const { data, } = await axios_1.default.post(keycloakTokenEndpoint, refreshData); if (!data.access_token || !data.refresh_token || !data.id_token) throw new CustomErrors_1.SSOError('There are UAA tokens missing.'); debug('Successfully refreshed access token'); return { accessToken: data.access_token, refreshToken: data.refresh_token, idToken: data.id_token, sessionState: oldConfig.tokens.sessionState, }; } catch (error) { debug('%O', error); throw new CustomErrors_1.SSOError(); } }; this.getTokenFromPasswordGrant = async ({ user, password, }) => { try { /** * This endpoint expects a x-form-url-encoded header, not JSON */ debug('getting token from password grant'); const postBody = querystring_1.default.stringify({ grant_type: 'password', client_id: this.CLIENT_ID, username: user, password, scope: 'openid', }); const { data, } = await axios_1.default.post(keycloakTokenEndpoint, postBody); if (!data.access_token || !data.refresh_token || !data.id_token || !data.session_state) throw new CustomErrors_1.SSOError('There are UAA tokens missing.'); return { accessToken: data.access_token, refreshToken: data.refresh_token, idToken: data.id_token, sessionState: data.session_state, }; } catch (error) { debug('%O', error); throw new CustomErrors_1.SSOError(); } }; /** * Spins up a hapi server, that listens to the callback from Keycloak * Once it receive a response, the promise is fulfilled and data is returned */ this._setupCallbackServerForGrant = async (caller) => { let redirectFilePath = caller === 'signin' ? this.KEYCLOAK_SIGNIN_FILEPATH : this.KEYCLOAK_SIGNUP_FILEPATH; return new Promise(async (resolve, reject) => { try { let responsePayload; await this.hapiServer.register(inert_1.default); // To read from a HTML file and return it this.hapiServer.route({ handler: async (req, reply) => { try { if (req.query.code) { await this.grantManager .obtainFromCode( /** * The following code is a hack to get the authentication token * to authorization token excahnge working. * * Keycloak expects a redirect_uri to be exactly the same * as the redirect_uri found when obtaining the authentication * token in the first place, but the keycloak_connect package * expects the variable to be stored in a specific way, as such: * * node_modules/keycloak-connect/middleware/auth-utils/grant-manager.js Line 98 */ { session: { auth_redirect_uri: this.CALLBACK_URL, }, }, req.query.code) .then((res) => { responsePayload = res; }); } else { redirectFilePath = this.KEYCLOAK_ERROR_FILEPATH; } // Sends the HTML that contains code to close the tab automatically return reply.file(redirectFilePath, { confine: false, }); } catch (err) { debug('%O', err); reject(err); } finally { if (responsePayload) { this.hapiServer.stop(); resolve(responsePayload); } else { cli_sdk_1.ux.spinner.stop('failed'); this.hapiServer.stop(); } } }, method: 'GET', path: `/${this.CALLBACK_ENDPOINT}`, }); /** * Starts the server and opens the login url */ await this.hapiServer.start(); } catch (err) { debug('%O', err); reject(err); } }); }; /** * Returns the URL used to invalidate the current user's session */ this.InvalidateSession = async (accessToken, refreshToken) => { await axios_1.default.post(`${env_1.OPS_KEYCLOAK_HOST}/realms/ops/protocol/openid-connect/logout`, querystring_1.default.stringify({ client_id: this.CLIENT_ID, refresh_token: refreshToken, }), { headers: { Authorization: accessToken, 'Content-Type': 'application/x-www-form-urlencoded', }, }); }; /** * Returns the necessary headers to invalidate the session */ this.buildInvalidateSessionHeaders = (sessionState, accessToken) => { return { Cookie: `$KEYCLOAK_SESSION=ops/${sessionState}; KEYCLOAK_IDENTITY=${accessToken}`, }; }; } // Needs to be in an `init` function because of the async call to get active port async init() { const CALLBACK_PORT = await (0, utils_1.getFirstActivePort)(this.POSSIBLE_PORTS); if (!CALLBACK_PORT) throw new Error('Cannot find available port'); this.CALLBACK_PORT = CALLBACK_PORT; this.hapiServer = new hapi_1.Server({ port: CALLBACK_PORT, host: this.CALLBACK_HOST, }); this.CALLBACK_URL = `http://${this.CALLBACK_HOST}:${CALLBACK_PORT}/${this.CALLBACK_ENDPOINT}`; } } exports.KeycloakService = KeycloakService;