@duosecurity/duo_universal
Version:
Node.js implementation of the Duo Universal SDK.
289 lines (288 loc) • 11.6 kB
JavaScript
"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;