UNPKG

15.9 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const tslib_1 = require("tslib");
4const config_1 = tslib_1.__importDefault(require("keycloak-connect/middleware/auth-utils/config"));
5const grant_manager_1 = tslib_1.__importDefault(require("keycloak-connect/middleware/auth-utils/grant-manager"));
6const hapi_1 = require("@hapi/hapi");
7const v1_1 = tslib_1.__importDefault(require("uuid/v1"));
8const querystring_1 = tslib_1.__importDefault(require("querystring"));
9const path_1 = tslib_1.__importDefault(require("path"));
10const inert_1 = tslib_1.__importDefault(require("@hapi/inert"));
11const open_1 = tslib_1.__importDefault(require("open"));
12const debug_1 = tslib_1.__importDefault(require("debug"));
13const utils_1 = require("../utils");
14const sdk_1 = require("@cto.ai/sdk");
15const axios_1 = tslib_1.__importDefault(require("axios"));
16const CustomErrors_1 = require("../errors/CustomErrors");
17const env_1 = require("../constants/env");
18const jsonwebtoken_1 = tslib_1.__importDefault(require("jsonwebtoken"));
19const debug = debug_1.default('ops:KeycloakService');
20const KEYCLOAK_CONFIG = {
21 realm: 'ops',
22 'auth-server-url': env_1.OPS_KEYCLOAK_HOST,
23 'ssl-required': 'external',
24 resource: 'ops-cli',
25 'public-client': true,
26 'confidential-port': 0,
27};
28/**
29 * The token endpoint which provides fresh tokens
30 * e.g.
31 * http://localhost:8080/auth/realms/ops/protocol/openid-connect/token
32 *
33 * for more info see: https://uaa.prod-platform.hc.ai/auth/realms/ops/.well-known/openid-configuration
34 */
35const keycloakTokenEndpoint = `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${KEYCLOAK_CONFIG.realm}/protocol/openid-connect/token`;
36const accountUrl = `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${KEYCLOAK_CONFIG.realm}/account/password`;
37class KeycloakService {
38 constructor(grantManager = new grant_manager_1.default(new config_1.default(KEYCLOAK_CONFIG))) {
39 this.grantManager = grantManager;
40 this.KEYCLOAK_SIGNIN_FILEPATH = path_1.default.join(__dirname, '../keycloakPages/signinRedirect.html');
41 this.KEYCLOAK_SIGNUP_FILEPATH = path_1.default.join(__dirname, '../keycloakPages/signupRedirect.html');
42 this.KEYCLOAK_ERROR_FILEPATH = path_1.default.join(__dirname, '../keycloakPages/errorRedirect.html');
43 this.KEYCLOAK_REALM = 'ops';
44 this.CALLBACK_HOST = 'localhost';
45 this.CALLBACK_ENDPOINT = 'callback';
46 this.CLIENT_ID = 'ops-cli';
47 this.CONFIDENTIAL_CLIENT_ID = 'ops-cli-confidential';
48 this.CALLBACK_PORT = null;
49 this.CALLBACK_URL = null;
50 this.POSSIBLE_PORTS = [10234, 28751, 38179, 41976, 49164];
51 this.hapiServer = {};
52 /**
53 * Generates the required query string params for standard flow
54 */
55 this._buildStandardFlowParams = () => {
56 const data = {
57 client_id: this.CLIENT_ID,
58 redirect_uri: this.CALLBACK_URL,
59 response_type: 'code',
60 scope: 'openid token',
61 nonce: v1_1.default(),
62 state: v1_1.default(),
63 };
64 return querystring_1.default.stringify(data);
65 };
66 /**
67 * Generates the initial URL with qury string parameters fire of to Keycloak
68 * e.g.
69 * http://localhost:8080/auth/realms/ops/protocol/openid-connect/auth?
70 * client_id=cli&
71 * redirect_uri=http%3A%2F%2Flocalhost%3A10234%2Fcallback&
72 * response_type=code&
73 * scope=openid%20token&
74 * nonce=12345678-1234-1234 -1234-12345678&
75 * state=12345678-1234-1234-1234-12345678
76 */
77 this._buildAuthorizeUrl = () => {
78 const params = this._buildStandardFlowParams();
79 return `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${this.KEYCLOAK_REALM}/protocol/openid-connect/auth?${params}`;
80 };
81 /**
82 * Converts the Keycloak Grant object to Tokens
83 */
84 this._formatGrantToTokens = (grant) => {
85 if (!grant ||
86 !grant.access_token ||
87 !grant.refresh_token ||
88 !grant.id_token ||
89 !grant.access_token.content ||
90 !grant.access_token.content.session_state) {
91 throw new CustomErrors_1.SSOError();
92 }
93 const accessToken = grant.access_token.token;
94 const refreshToken = grant.refresh_token.token;
95 const idToken = grant.id_token.token;
96 const sessionState = grant.access_token.content.session_state;
97 if (!accessToken || !refreshToken || !idToken)
98 throw new CustomErrors_1.SSOError();
99 return {
100 accessToken,
101 refreshToken,
102 idToken,
103 sessionState,
104 };
105 };
106 /**
107 * Opens the signin URL and sets up the server for callback
108 */
109 this.keycloakSignInFlow = async () => {
110 open_1.default(this._buildAuthorizeUrl());
111 const grant = await this._setupCallbackServerForGrant('signin');
112 return this._formatGrantToTokens(grant);
113 };
114 /**
115 * Generates the initial URL with qury string parameters fire of to Keycloak
116 * e.g.
117 * http://localhost:8080/auth/realms/ops/protocol/openid-connect/registrations?
118 * client_id=www-dev
119 * response_type=code
120 */
121 this._buildRegisterUrl = () => {
122 const params = this._buildStandardFlowParams();
123 return `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${this.KEYCLOAK_REALM}/protocol/openid-connect/registrations?${params}`;
124 };
125 /**
126 * Opens the signup link in the browser, and listen for it's response
127 */
128 this.keycloakSignUpFlow = async () => {
129 const registerUrl = this._buildRegisterUrl();
130 open_1.default(registerUrl);
131 console.log(`\n💻 Please follow the prompts in the browser window and verify your email address before logging in`);
132 console.log(`\n If the link doesn't open, please click the following URL ${sdk_1.ux.colors.dim(registerUrl)} \n\n`);
133 const grant = await this._setupCallbackServerForGrant('signup');
134 return this._formatGrantToTokens(grant);
135 };
136 /**
137 * Generates the initial URL with query string parameters fired off to Keycloak
138 * e.g.
139 * http://localhost:8080/auth/realms/ops/login-actions/reset-credentials?client_id=cli
140 */
141 this._buildResetUrl = () => {
142 const data = {
143 client_id: this.CLIENT_ID,
144 };
145 const params = querystring_1.default.stringify(data);
146 return `${KEYCLOAK_CONFIG['auth-server-url']}/realms/${this.KEYCLOAK_REALM}/login-actions/reset-credentials?${params}`;
147 };
148 this.keycloakResetFlow = (isUserSignedIn) => {
149 // open up account page if the user is signed in otherwise open up password reset page
150 const url = isUserSignedIn ? accountUrl : this._buildResetUrl();
151 open_1.default(url);
152 console.log(`\n💻 Please follow the prompts in the browser window`);
153 console.log(`\n If the link doesn't open, please click the following URL: ${sdk_1.ux.colors.dim(url)} \n\n`);
154 };
155 /*
156 * this is necessary because we have two clients and one is confidential. Only
157 * the confidential client requires a client secret.
158 */
159 this.includeClientSecret = (clientName) => {
160 return clientName === this.CLIENT_ID
161 ? {}
162 : {
163 client_secret: env_1.OPS_CLIENT_SECRET,
164 };
165 };
166 this.refreshAccessToken = async (oldConfig, refreshToken) => {
167 try {
168 debug('Starting to refresh access token');
169 const decodedToken = jsonwebtoken_1.default.decode(refreshToken);
170 /*
171 * the refresh token contains the client ID (azp). There are two possible
172 * clients, ops-cli-confidential or ops-cli. The client ID in the request params
173 * has to match the client ID embedded in the token.
174 */
175 const clientName = decodedToken && decodedToken.azp ? decodedToken.azp : this.CLIENT_ID;
176 /**
177 * This endpoint expects a x-form-url-encoded header, not JSON
178 */
179 const refreshData = querystring_1.default.stringify(Object.assign({ grant_type: 'refresh_token', client_id: clientName, refresh_token: refreshToken }, this.includeClientSecret(clientName)));
180 const { data, } = await axios_1.default.post(keycloakTokenEndpoint, refreshData);
181 if (!data.access_token || !data.refresh_token || !data.id_token)
182 throw new CustomErrors_1.SSOError('There are UAA tokens missing.');
183 debug('Successfully refreshed access token');
184 return {
185 accessToken: data.access_token,
186 refreshToken: data.refresh_token,
187 idToken: data.id_token,
188 sessionState: oldConfig.tokens.sessionState,
189 };
190 }
191 catch (error) {
192 debug('%O', error);
193 // console.log({ error })
194 throw new CustomErrors_1.SSOError();
195 }
196 };
197 this.getTokenFromPasswordGrant = async ({ user, password, }) => {
198 try {
199 /**
200 * This endpoint expects a x-form-url-encoded header, not JSON
201 */
202 debug('getting token from password grant');
203 const postBody = querystring_1.default.stringify({
204 grant_type: 'password',
205 client_id: this.CONFIDENTIAL_CLIENT_ID,
206 client_secret: env_1.OPS_CLIENT_SECRET,
207 username: user,
208 password,
209 scope: 'openid',
210 });
211 const { data, } = await axios_1.default.post(keycloakTokenEndpoint, postBody);
212 if (!data.access_token ||
213 !data.refresh_token ||
214 !data.id_token ||
215 !data.session_state)
216 throw new CustomErrors_1.SSOError('There are UAA tokens missing.');
217 return {
218 accessToken: data.access_token,
219 refreshToken: data.refresh_token,
220 idToken: data.id_token,
221 sessionState: data.session_state,
222 };
223 }
224 catch (error) {
225 debug('%O', error);
226 throw new CustomErrors_1.SSOError();
227 }
228 };
229 /**
230 * Spins up a hapi server, that listens to the callback from Keycloak
231 * Once it receive a response, the promise is fulfilled and data is returned
232 */
233 this._setupCallbackServerForGrant = async (caller) => {
234 let redirectFilePath = caller === 'signin'
235 ? this.KEYCLOAK_SIGNIN_FILEPATH
236 : this.KEYCLOAK_SIGNUP_FILEPATH;
237 return new Promise(async (resolve, reject) => {
238 try {
239 let responsePayload;
240 await this.hapiServer.register(inert_1.default); // To read from a HTML file and return it
241 this.hapiServer.route({
242 method: 'GET',
243 path: `/${this.CALLBACK_ENDPOINT}`,
244 handler: async (req, reply) => {
245 try {
246 if (req.query.code) {
247 await this.grantManager
248 .obtainFromCode(
249 /**
250 * The following code is a hack to get the authentication token
251 * to authorization token excahnge working.
252 *
253 * Keycloak expects a redirect_uri to be exactly the same
254 * as the redirect_uri found when obtaining the authentication
255 * token in the first place, but the keycloak_connect package
256 * expects the variable to be stored in a specific way, as such:
257 *
258 * node_modules/keycloak-connect/middleware/auth-utils/grant-manager.js Line 98
259 */
260 {
261 session: {
262 auth_redirect_uri: this.CALLBACK_URL,
263 },
264 }, req.query.code)
265 .then((res) => {
266 responsePayload = res;
267 });
268 }
269 else {
270 redirectFilePath = this.KEYCLOAK_ERROR_FILEPATH;
271 }
272 // Sends the HTML that contains code to close the tab automatically
273 return reply.file(redirectFilePath, {
274 confine: false,
275 });
276 }
277 catch (err) {
278 debug('%O', err);
279 reject(err);
280 }
281 finally {
282 if (responsePayload) {
283 this.hapiServer.stop();
284 resolve(responsePayload);
285 }
286 else {
287 sdk_1.ux.spinner.stop('failed');
288 this.hapiServer.stop();
289 }
290 }
291 },
292 });
293 /**
294 * Starts the server and opens the login url
295 */
296 await this.hapiServer.start();
297 }
298 catch (err) {
299 debug('%O', err);
300 reject(err);
301 }
302 });
303 };
304 /**
305 * Returns the URL used to invalidate the current user's session
306 */
307 this.buildInvalidateSessionUrl = () => {
308 return `${env_1.OPS_KEYCLOAK_HOST}/realms/ops/protocol/openid-connect/logout`;
309 };
310 /**
311 * Returns the necessary headers to invalidate the session
312 */
313 this.buildInvalidateSessionHeaders = (sessionState, accessToken) => {
314 return {
315 Cookie: `$KEYCLOAK_SESSION=ops/${sessionState}; KEYCLOAK_IDENTITY=${accessToken}`,
316 };
317 };
318 }
319 // Needs to be in an `init` function because of the async call to get active port
320 async init() {
321 const CALLBACK_PORT = await utils_1.getFirstActivePort(this.POSSIBLE_PORTS);
322 if (!CALLBACK_PORT)
323 throw new Error('Cannot find available port');
324 this.CALLBACK_PORT = CALLBACK_PORT;
325 this.hapiServer = new hapi_1.Server({
326 port: CALLBACK_PORT,
327 host: this.CALLBACK_HOST,
328 });
329 this.CALLBACK_URL = `http://${this.CALLBACK_HOST}:${CALLBACK_PORT}/${this.CALLBACK_ENDPOINT}`;
330 }
331}
332exports.KeycloakService = KeycloakService;