node-expose-sspi-strict
Version:
Expose the Microsoft Windows SSPI interface in order to do NTLM and Kerberos authentication.
272 lines (256 loc) • 8.12 kB
text/typescript
import fetch, { RequestInit, Response } from 'node-fetch';
import * as dns from 'dns';
import {
sysinfo,
sspi,
InitializeSecurityContextInput,
AcquireCredHandleInput,
SecuritySupportProvider,
} from '../../lib/api';
import {} from './domain';
import { encode, decode } from 'base64-arraybuffer';
import dbg from 'debug';
import { CookieList } from './interfaces';
const debug = dbg('node-expose-sspi:client');
// Thanks to :
// -
// -
/**
* Get the SPN the same way Chrome/Firefox or IE does.
*
* Links:
* - getting the domain name: https://stackoverflow.com/questions/8498592/extract-hostname-name-from-string
* - algo of IE : https://support.microsoft.com/en-us/help/4551934/kerberos-failures-in-internet-explorer
*
* @param {string} url
* @returns {string}
*/
export async function getSPNFromURI(url: string): Promise<string> {
const msDomainName = sysinfo.GetComputerNameEx('ComputerNameDnsDomain');
if (msDomainName.length === 0) {
debug('Client running on a host that is not part of a Microsoft domain');
return 'whatever';
}
const matches = /^https?\:\/\/([^\/:?#]+)(?:[\/:?#]|$)/i.exec(url);
const urlDomain = matches && matches[1];
if (!urlDomain) {
throw new Error('url is not well parsed. url=' + url);
}
debug('urlDomain: ', urlDomain);
if (['localhost', '127.0.0.1'].includes(urlDomain)) {
return 'HTTP/localhost';
}
// needs urlFQDN for the DNS resolver.
const urlFQDN = urlDomain.includes('.')
? urlDomain
: urlDomain + '.' + msDomainName;
let hostname = urlFQDN;
try {
while (true) {
const records = await dns.promises.resolve(hostname, 'CNAME');
debug('records', records);
if (records.length === 0) {
break;
}
hostname = records[0];
}
} catch (e) {
debug('DNS error', e);
}
const result = 'HTTP/' + hostname;
debug('result: ', result);
return result;
}
/**
* Allow to fetch url with a system that uses the negotiate protocol.
* Cookies are managed if necessary during the process.
*
* @export
* @class Client
*/
export class Client {
private cookieList: CookieList = {};
private domain: string;
private user: string;
private password: string;
private targetName: string;
private ssp: SecuritySupportProvider = 'Negotiate';
private saveCookies(response: Response): void {
response.headers.forEach((value, name) => {
if (name !== 'Set-Cookie'.toLowerCase()) {
return;
}
// parse something like <key>=<val>[; Expires=xxxxx;]
const [key, val] = value.split(/[=;]/g);
debug('val: ', val);
debug('key: ', key);
this.cookieList[key] = val;
});
debug('cookieList: ', this.cookieList);
}
private restituteCookies(requestInit: RequestInit): void {
const cookieStr = Object.keys(this.cookieList)
.map((key) => key + '=' + this.cookieList[key])
.join('; ');
if (cookieStr.length === 0) {
return;
}
Object.assign(requestInit.headers, { cookie: cookieStr });
}
/**
* Set the credentials for running the client as another user.
*
* By default, the credentials are the logged windows account.
*
* @param {string} domain
* @param {string} user
* @param {string} password
* @memberof Client
*/
setCredentials(domain: string, user: string, password: string): void {
this.domain = domain;
this.user = user;
this.password = password;
}
/**
* Force the targetName to a value.
*
* For Kerberos, the targetName is the SPN (Service Principal Name).
*
* @param {string} targetName
* @memberof Client
*/
setTargetName(targetName: string): void {
this.targetName = targetName;
}
/**
* Set the Security Support Provider (NTLM, Kerberos, Negotiate)
*
* @param {SecuritySupportProvider} ssp
* @memberof Client
*/
setSSP(ssp: SecuritySupportProvider): void {
this.ssp = ssp;
}
/**
* Works as the fetch function of node-fetch node module.
* This function can handle the negotiate protocol with SPNEGO tokens.
*
* @param {string} resource - the URL to fetch
* @param {RequestInit} [init] - the options (headers, body, etc.)
* @returns {Promise<Response>} a promise with the HTTP response.
* @memberof Client
*/
async fetch(resource: string, init?: RequestInit): Promise<Response> {
const response = await fetch(resource, init);
const result = await this.handleAuth(response, resource, init);
return result;
}
/**
* The authentication negotiate protocol is handled by this function.
* It is called by `Client.fetch`.
*
* @private
* @param {Response} response
* @param {string} resource
* @param {RequestInit} [init={}]
* @returns {Promise<Response>}
* @memberof Client
*/
private async handleAuth(
response: Response,
resource: string,
init: RequestInit = {}
): Promise<Response> {
debug('start response.headers', response.headers);
// has cookies ?
this.saveCookies(response);
if (!response.headers.has('www-authenticate')) {
debug('no header www-authenticate');
return response;
}
if (response && !response.headers.get('www-authenticate')!.startsWith('Negotiate')) {
debug(
'no header www-authenticate with Negotiate:',
response.headers.get('www-authenticate')
);
return response;
}
if (response.status !== 401) {
debug('no status 401');
return response;
}
debug('starting negotiate auth');
const credInput = {
packageName: this.ssp,
credentialUse: 'SECPKG_CRED_OUTBOUND',
} as AcquireCredHandleInput;
if (this.user) {
credInput.authData = {
domain: this.domain,
user: this.user,
password: this.password,
};
}
const clientCred = sspi.AcquireCredentialsHandle(credInput);
const packageInfo = sspi.QuerySecurityPackageInfo(this.ssp);
const targetName = this.targetName || (await getSPNFromURI(resource));
let input: InitializeSecurityContextInput = {
credential: clientCred.credential,
targetName,
cbMaxToken: packageInfo.cbMaxToken,
targetDataRep: 'SECURITY_NATIVE_DREP',
};
debug('input: ', input);
let clientSecurityContext = sspi.InitializeSecurityContext(input);
// encode to Base64 and send via HTTP
let base64 = encode(clientSecurityContext.SecBufferDesc.buffers[0]);
let requestInit: RequestInit = { ...init };
requestInit.headers = {
...init.headers,
Authorization: 'Negotiate ' + base64,
};
// cookies case
this.restituteCookies(requestInit);
debug('first requestInit.headers', requestInit.headers);
response = await fetch(resource, requestInit);
debug('first response.headers', response.headers);
this.saveCookies(response);
while (
response.headers.has('www-authenticate') &&
response.status === 401 &&
response.headers.get('www-authenticate')!.startsWith('Negotiate ')
) {
const buffer = decode(
response.headers.get('www-authenticate')!.substring('Negotiate '.length)
);
input = {
credential: clientCred.credential,
targetName,
cbMaxToken: packageInfo.cbMaxToken,
serverSecurityContext: {
SecBufferDesc: {
ulVersion: 0,
buffers: [buffer],
},
},
contextHandle: clientSecurityContext.contextHandle,
targetDataRep: 'SECURITY_NATIVE_DREP',
};
clientSecurityContext = sspi.InitializeSecurityContext(input);
base64 = encode(clientSecurityContext.SecBufferDesc.buffers[0]);
requestInit = { ...init };
requestInit.headers = {
...init.headers,
Authorization: 'Negotiate ' + base64,
};
this.restituteCookies(requestInit);
debug('other requestInit.headers', requestInit.headers);
response = await fetch(resource, requestInit);
debug('other response.headers', response.headers);
this.saveCookies(response);
}
debug('handleAuth: end');
return response;
}
}