@cto.ai/ops
Version:
š» CTO.ai - The CLI built for Teams š
335 lines (334 loc) ⢠15.9 kB
JavaScript
;
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;