UNPKG

@duosecurity/duo_universal

Version:
289 lines (288 loc) 11.6 kB
"use strict"; // SPDX-FileCopyrightText: 2021 Lukas Hroch // SPDX-FileCopyrightText: 2022 Cisco Systems, Inc. and/or its affiliates // // SPDX-License-Identifier: MIT var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Client = void 0; const axios_1 = __importDefault(require("axios")); const https_1 = __importDefault(require("https")); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const url_1 = require("url"); const constants = __importStar(require("./constants")); const duo_exception_1 = require("./duo-exception"); const util_1 = require("./util"); class Client { constructor(options) { this.HEALTH_CHECK_ENDPOINT = '/oauth/v1/health_check'; this.AUTHORIZE_ENDPOINT = '/oauth/v1/authorize'; this.TOKEN_ENDPOINT = '/oauth/v1/token'; this.validateInitialConfig(options); const { clientId, clientSecret, apiHost, redirectUrl, useDuoCodeAttribute } = options; this.clientId = clientId; this.clientSecret = clientSecret; this.apiHost = apiHost; this.baseURL = `https://${this.apiHost}`; this.redirectUrl = redirectUrl; this.useDuoCodeAttribute = useDuoCodeAttribute !== null && useDuoCodeAttribute !== void 0 ? useDuoCodeAttribute : true; const agent = new https_1.default.Agent({ ca: constants.DUO_PINNED_CERT, }); this.axios = axios_1.default.create({ baseURL: this.baseURL, httpsAgent: agent, httpAgent: Error('HTTP disabled. Must use HTTPS'), }); } /** * Validate that the clientId and clientSecret are the proper length. * * @private * @param {ClientOptions} options * @memberof Client */ validateInitialConfig(options) { const { clientId, clientSecret, apiHost, redirectUrl } = options; if (clientId.length !== constants.CLIENT_ID_LENGTH) throw new duo_exception_1.DuoException(constants.INVALID_CLIENT_ID_ERROR); if (clientSecret.length !== constants.CLIENT_SECRET_LENGTH) throw new duo_exception_1.DuoException(constants.INVALID_CLIENT_SECRET_ERROR); if (apiHost === '') throw new duo_exception_1.DuoException(constants.PARSING_CONFIG_ERROR); try { new url_1.URL(redirectUrl); } catch (err) { throw new duo_exception_1.DuoException(constants.PARSING_CONFIG_ERROR); } } /** * Retrieves exception message for DuoException from HTTPS result message. * * @private * @param {*} result * @returns {string} * @memberof Client */ getExceptionFromResult(result) { const { message, message_detail, error, error_description } = result; if (message && message_detail) return `${message}: ${message_detail}`; if (error && error_description) return `${error}: ${error_description}`; return constants.MALFORMED_RESPONSE; } /** * Create client JWT payload * * @private * @param {string} audience * @returns {string} * @memberof Client */ createJwtPayload(audience) { const timeInSecs = (0, util_1.getTimeInSeconds)(); const payload = { iss: this.clientId, sub: this.clientId, aud: audience, jti: (0, util_1.generateRandomString)(constants.JTI_LENGTH), iat: timeInSecs, exp: timeInSecs + constants.JWT_EXPIRATION, }; return jsonwebtoken_1.default.sign(payload, this.clientSecret, { algorithm: constants.SIG_ALGORITHM }); } /** * Verify JWT token * * @private * @template T * @param {string} token * @returns {Promise<T>} * @memberof Client */ async verifyToken(token) { const tokenEndpoint = `${this.baseURL}${this.TOKEN_ENDPOINT}`; const clientId = this.clientId; return new Promise((resolve, reject) => { jsonwebtoken_1.default.verify(token, this.clientSecret, { algorithms: [constants.SIG_ALGORITHM], clockTolerance: constants.JWT_LEEWAY, issuer: tokenEndpoint, audience: clientId, }, (err, decoded) => err || !decoded ? reject(new duo_exception_1.DuoException(constants.JWT_DECODE_ERROR, err)) : resolve(decoded)); }); } /** * Error handler to throw relevant error * * @private * @param {unknown} error * @returns {never} * @memberof Client */ handleErrorResponse(error) { var _a; if (error instanceof duo_exception_1.DuoException) throw error; if (axios_1.default.isAxiosError(error)) { const data = (_a = error.response) === null || _a === void 0 ? void 0 : _a.data; throw new duo_exception_1.DuoException(data ? this.getExceptionFromResult(data) : error.message, error); } if (error instanceof Error) throw new duo_exception_1.DuoException(error.message, error); throw new duo_exception_1.DuoException(constants.MALFORMED_RESPONSE); } /** * Generate a random hex string with a length of DEFAULT_STATE_LENGTH. * * @returns {string} * @memberof Client */ generateState() { return (0, util_1.generateRandomString)(constants.DEFAULT_STATE_LENGTH); } /** * Makes a call to HEALTH_CHECK_ENDPOINT to see if Duo is available. * * @returns {Promise<HealthCheckResponse>} * @memberof Client */ async healthCheck() { const audience = `${this.baseURL}${this.HEALTH_CHECK_ENDPOINT}`; const jwtPayload = this.createJwtPayload(audience); const request = { client_id: this.clientId, client_assertion: jwtPayload, }; try { const { data } = await this.axios.post(this.HEALTH_CHECK_ENDPOINT, new url_1.URLSearchParams(request)); const { stat } = data; if (!stat || stat !== 'OK') throw new duo_exception_1.DuoException(this.getExceptionFromResult(data)); return data; } catch (err) { this.handleErrorResponse(err); } } /** * Generate URI to redirect to for the Duo prompt. * * @param {string} username * @param {string} state * @returns {string} * @memberof Client */ createAuthUrl(username, state) { if (!username) throw new duo_exception_1.DuoException(constants.DUO_USERNAME_ERROR); if (!state || state.length < constants.MIN_STATE_LENGTH || state.length > constants.MAX_STATE_LENGTH) throw new duo_exception_1.DuoException(constants.DUO_STATE_ERROR); const timeInSecs = (0, util_1.getTimeInSeconds)(); const payload = { response_type: 'code', scope: 'openid', exp: timeInSecs + constants.JWT_EXPIRATION, client_id: this.clientId, redirect_uri: this.redirectUrl, state, duo_uname: username, iss: this.clientId, aud: this.baseURL, use_duo_code_attribute: this.useDuoCodeAttribute, }; const request = jsonwebtoken_1.default.sign(payload, this.clientSecret, { algorithm: constants.SIG_ALGORITHM }); const query = { response_type: 'code', client_id: this.clientId, request: request, redirect_uri: this.redirectUrl, scope: 'openid', }; return `${this.baseURL}${this.AUTHORIZE_ENDPOINT}?${new url_1.URLSearchParams(query).toString()}`; } /** * Exchange a code returned by Duo for a token that contains information about the authorization. * * @param {string} code * @param {string} username * @param {(string | null)} [nonce=null] * @returns {Promise<TokenResponsePayload>} * @memberof Client */ async exchangeAuthorizationCodeFor2FAResult(code, username, nonce = null) { if (!code) throw new duo_exception_1.DuoException(constants.MISSING_CODE_ERROR); if (!username) throw new duo_exception_1.DuoException(constants.USERNAME_ERROR); const tokenEndpoint = `${this.baseURL}${this.TOKEN_ENDPOINT}`; const jwtPayload = this.createJwtPayload(tokenEndpoint); const request = { grant_type: constants.GRANT_TYPE, code, redirect_uri: this.redirectUrl, client_id: this.clientId, client_assertion_type: constants.CLIENT_ASSERTION_TYPE, client_assertion: jwtPayload, }; try { const { data } = await this.axios.post(this.TOKEN_ENDPOINT, new url_1.URLSearchParams(request), { headers: { 'user-agent': `${constants.USER_AGENT} node/${process.versions.node} v8/${process.versions.v8}`, }, }); /* Verify that we are receiving the expected response from Duo */ const resultKeys = Object.keys(data); const requiredKeys = ['id_token', 'access_token', 'expires_in', 'token_type']; if (requiredKeys.some((key) => !resultKeys.includes(key))) throw new duo_exception_1.DuoException(constants.MALFORMED_RESPONSE); if (data.token_type !== 'Bearer') throw new duo_exception_1.DuoException(constants.MALFORMED_RESPONSE); const token = await this.verifyToken(data.id_token); const tokenKeys = Object.keys(token); const requiredTokenKeys = ['exp', 'iat', 'iss', 'aud']; if (requiredTokenKeys.some((key) => !tokenKeys.includes(key))) throw new duo_exception_1.DuoException(constants.MALFORMED_RESPONSE); if (!token.preferred_username || token.preferred_username !== username) throw new duo_exception_1.DuoException(constants.USERNAME_ERROR); if (nonce && (!token.nonce || token.nonce !== nonce)) throw new duo_exception_1.DuoException(constants.NONCE_ERROR); return token; } catch (err) { this.handleErrorResponse(err); } } } exports.Client = Client;